Преглед изворни кода

implemented pr (worth fixing) feedback

update commit
behrinml пре 1 месец
родитељ
комит
dd1d40b0d4
56 измењених фајлова са 1600 додато и 277 уклоњено
  1. 106 66
      backend/app/api/routes/finance.py
  2. 1 0
      backend/app/api/routes/notification_templates.py
  3. 2 0
      backend/app/api/routes/notifications.py
  4. 82 58
      backend/app/core/database.py
  5. 109 13
      backend/app/main.py
  6. 3 0
      backend/app/models/archive.py
  7. 5 0
      backend/app/models/finance.py
  8. 1 0
      backend/app/models/notification.py
  9. 6 0
      backend/app/models/notification_template.py
  10. 3 0
      backend/app/models/print_queue.py
  11. 2 0
      backend/app/schemas/notification.py
  12. 10 0
      backend/app/schemas/notification_template.py
  13. 4 1
      backend/app/services/bambu_mqtt.py
  14. 5 1
      backend/app/services/finance_balance.py
  15. 88 27
      backend/app/services/finance_billing.py
  16. 45 32
      backend/app/services/finance_budget.py
  17. 10 3
      backend/app/services/finance_defaults.py
  18. 33 0
      backend/app/services/notification_service.py
  19. 12 2
      backend/app/services/print_cost_estimate.py
  20. 38 26
      backend/app/services/print_scheduler.py
  21. 1 0
      backend/tests/conftest.py
  22. 247 4
      backend/tests/integration/test_finance_api.py
  23. 19 0
      backend/tests/integration/test_notifications_api.py
  24. 27 0
      backend/tests/integration/test_print_queue_api.py
  25. 73 1
      backend/tests/integration/test_scheduler_budget_reservation.py
  26. 24 0
      backend/tests/unit/services/test_bambu_mqtt.py
  27. 186 1
      backend/tests/unit/services/test_finance_service_billing.py
  28. 20 0
      backend/tests/unit/services/test_finance_service_defaults.py
  29. 34 0
      backend/tests/unit/services/test_notification_service.py
  30. 16 0
      backend/tests/unit/services/test_print_cost_estimate.py
  31. 36 0
      backend/tests/unit/test_billing_run_id_migration.py
  32. 14 2
      backend/tests/unit/test_finance_table_migration.py
  33. 26 4
      backend/tests/unit/test_printer_kill_switch.py
  34. 27 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  35. 37 0
      frontend/src/__tests__/components/PrintModalBilling.test.tsx
  36. 28 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  37. 3 0
      frontend/src/api/client.ts
  38. 10 0
      frontend/src/components/AddNotificationModal.tsx
  39. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  40. 6 0
      frontend/src/components/PrintModal/CostCenterSelect.tsx
  41. 23 1
      frontend/src/components/PrintModal/index.tsx
  42. 7 0
      frontend/src/hooks/useWebSocket.ts
  43. 9 2
      frontend/src/i18n/locales/de.ts
  44. 8 1
      frontend/src/i18n/locales/en.ts
  45. 8 1
      frontend/src/i18n/locales/es.ts
  46. 8 1
      frontend/src/i18n/locales/fr.ts
  47. 8 1
      frontend/src/i18n/locales/it.ts
  48. 8 1
      frontend/src/i18n/locales/ja.ts
  49. 9 2
      frontend/src/i18n/locales/ko.ts
  50. 8 1
      frontend/src/i18n/locales/pt-BR.ts
  51. 8 1
      frontend/src/i18n/locales/ru.ts
  52. 8 1
      frontend/src/i18n/locales/tr.ts
  53. 8 1
      frontend/src/i18n/locales/uk.ts
  54. 8 1
      frontend/src/i18n/locales/zh-CN.ts
  55. 8 1
      frontend/src/i18n/locales/zh-TW.ts
  56. 51 20
      frontend/src/pages/FinancePage.tsx

+ 106 - 66
backend/app/api/routes/finance.py

@@ -10,7 +10,6 @@ from sqlalchemy.orm import selectinload
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
-from backend.app.models.archive import PrintArchive
 from backend.app.models.finance import (
 from backend.app.models.finance import (
     BudgetReservation,
     BudgetReservation,
     CostCenter,
     CostCenter,
@@ -20,7 +19,6 @@ from backend.app.models.finance import (
     WalletTransaction,
     WalletTransaction,
     normalize_transaction_type,
     normalize_transaction_type,
 )
 )
-from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.finance import (
 from backend.app.schemas.finance import (
@@ -44,6 +42,7 @@ from backend.app.services.finance_balance import (
     personal_balance_condition,
     personal_balance_condition,
     sync_personal_wallet_balance,
     sync_personal_wallet_balance,
 )
 )
+from backend.app.services.finance_budget import get_cost_center_reserved_map
 
 
 router = APIRouter(prefix="/finance", tags=["finance"])
 router = APIRouter(prefix="/finance", tags=["finance"])
 
 
@@ -132,6 +131,7 @@ async def _get_cost_center_usage_maps(
         .where(
         .where(
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.is_not(None),
             WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
         )
         )
         .group_by(WalletTransaction.cost_center_id)
         .group_by(WalletTransaction.cost_center_id)
     )
     )
@@ -143,6 +143,7 @@ async def _get_cost_center_usage_maps(
         .where(
         .where(
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.is_not(None),
             WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
             WalletTransaction.created_at >= budget_window_start_utc,
             WalletTransaction.created_at >= budget_window_start_utc,
         )
         )
         .group_by(WalletTransaction.cost_center_id)
         .group_by(WalletTransaction.cost_center_id)
@@ -165,6 +166,7 @@ async def _get_cost_center_balance_map(
         .where(
         .where(
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.is_not(None),
             WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
         )
         )
         .group_by(WalletTransaction.cost_center_id)
         .group_by(WalletTransaction.cost_center_id)
     )
     )
@@ -175,31 +177,7 @@ async def _get_cost_center_reserved_map(
     db: AsyncSession,
     db: AsyncSession,
     cost_center_ids: list[int],
     cost_center_ids: list[int],
 ) -> dict[int, float]:
 ) -> dict[int, float]:
-    if not cost_center_ids:
-        return {}
-
-    budget_rows = await db.execute(
-        select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
-        .where(
-            BudgetReservation.cost_center_id.in_(cost_center_ids),
-            BudgetReservation.status == "active",
-        )
-        .group_by(BudgetReservation.cost_center_id)
-    )
-    reserved_map = {int(center_id): float(value) for center_id, value in budget_rows.all() if center_id is not None}
-
-    queue_rows = await db.execute(
-        select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
-        .where(
-            PrintQueueItem.cost_center_id.in_(cost_center_ids),
-            PrintQueueItem.status.in_(("pending", "printing")),
-        )
-        .group_by(PrintQueueItem.cost_center_id)
-    )
-    for center_id, value in queue_rows.all():
-        if center_id is not None:
-            reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
-    return reserved_map
+    return await get_cost_center_reserved_map(db, cost_center_ids)
 
 
 
 
 def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
 def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
@@ -306,24 +284,47 @@ def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
     )
     )
 
 
 
 
-async def _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[int, float]:
-    result = await db.execute(
+async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
+    """Return a balance without creating a wallet row from a GET request."""
+    wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
+    if wallet is not None:
+        return _to_balance_response(wallet)
+    return WalletBalanceResponse(
+        user_id=user_id,
+        balance=await calculate_personal_balance(db, user_id),
+        currency="EUR",
+        updated_at=None,
+    )
+
+
+async def _build_personal_balance_map(
+    db: AsyncSession,
+    user_id: int,
+    transaction_ids: list[int],
+) -> dict[int, float]:
+    """Return running balances only for transactions on the requested page."""
+    if not transaction_ids:
+        return {}
+
+    running = (
         select(
         select(
-            WalletTransaction.id,
-            WalletTransaction.amount,
+            WalletTransaction.id.label("transaction_id"),
+            func.sum(WalletTransaction.amount)
+            .over(order_by=(WalletTransaction.created_at.asc(), WalletTransaction.id.asc()))
+            .label("running_balance"),
         )
         )
         .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
         .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
-        .where(WalletTransaction.user_id == user_id, personal_balance_condition(user_id))
-        .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+        .where(
+            WalletTransaction.user_id == user_id,
+            WalletTransaction.is_voided.is_(False),
+            personal_balance_condition(user_id),
+        )
+        .subquery()
     )
     )
-
-    running_balance = 0.0
-    balance_map: dict[int, float] = {}
-    for transaction_id, amount in result.all():
-        running_balance += float(amount)
-        balance_map[int(transaction_id)] = running_balance
-
-    return balance_map
+    result = await db.execute(
+        select(running.c.transaction_id, running.c.running_balance).where(running.c.transaction_id.in_(transaction_ids))
+    )
+    return {int(transaction_id): round(float(balance), 2) for transaction_id, balance in result.all()}
 
 
 
 
 async def _create_wallet_adjustment(
 async def _create_wallet_adjustment(
@@ -356,6 +357,7 @@ async def _create_wallet_adjustment(
         result = await db.execute(
         result = await db.execute(
             select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
             select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
                 WalletTransaction.cost_center_id == cost_center_id,
                 WalletTransaction.cost_center_id == cost_center_id,
+                WalletTransaction.is_voided.is_(False),
             )
             )
         )
         )
         current_cc_balance = float(result.scalar() or 0.0)
         current_cc_balance = float(result.scalar() or 0.0)
@@ -423,19 +425,22 @@ async def get_my_transactions(
     user = await _require_authenticated_user(current_user)
     user = await _require_authenticated_user(current_user)
 
 
     total_result = await db.execute(
     total_result = await db.execute(
-        select(func.count(WalletTransaction.id)).where(WalletTransaction.user_id == user.id)
+        select(func.count(WalletTransaction.id)).where(
+            WalletTransaction.user_id == user.id,
+            WalletTransaction.is_voided.is_(False),
+        )
     )
     )
     total = int(total_result.scalar_one() or 0)
     total = int(total_result.scalar_one() or 0)
 
 
     result = await db.execute(
     result = await db.execute(
         select(WalletTransaction)
         select(WalletTransaction)
-        .where(WalletTransaction.user_id == user.id)
+        .where(WalletTransaction.user_id == user.id, WalletTransaction.is_voided.is_(False))
         .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
         .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
         .limit(limit)
         .limit(limit)
         .offset(offset)
         .offset(offset)
     )
     )
     transactions = result.scalars().all()
     transactions = result.scalars().all()
-    personal_balance_map = await _build_personal_balance_map(db, user.id)
+    personal_balance_map = await _build_personal_balance_map(db, user.id, [tx.id for tx in transactions])
     return WalletTransactionListResponse(
     return WalletTransactionListResponse(
         items=[
         items=[
             _serialize_wallet_transaction(tx).model_copy(
             _serialize_wallet_transaction(tx).model_copy(
@@ -460,7 +465,7 @@ async def get_all_transactions(
     """Return wallet ledger entries across users for admin finance view."""
     """Return wallet ledger entries across users for admin finance view."""
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
 
 
-    conditions = []
+    conditions = [WalletTransaction.is_voided.is_(False)]
     if user_id is not None:
     if user_id is not None:
         await _get_user_or_404(db, user_id)
         await _get_user_or_404(db, user_id)
         conditions.append(WalletTransaction.user_id == user_id)
         conditions.append(WalletTransaction.user_id == user_id)
@@ -500,21 +505,23 @@ async def delete_transaction(
     """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
     """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
 
 
-    result = await db.execute(select(WalletTransaction).where(WalletTransaction.id == transaction_id))
+    result = await db.execute(
+        select(WalletTransaction).where(
+            WalletTransaction.id == transaction_id,
+            WalletTransaction.is_voided.is_(False),
+        )
+    )
     tx = result.scalar_one_or_none()
     tx = result.scalar_one_or_none()
     if tx is None:
     if tx is None:
         raise HTTPException(status_code=404, detail="Transaction not found")
         raise HTTPException(status_code=404, detail="Transaction not found")
 
 
     user_id = tx.user_id
     user_id = tx.user_id
 
 
-    if tx.transaction_type == "print_charge" and tx.print_archive_id is not None:
-        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == tx.print_archive_id))
-        archive = archive_result.scalar_one_or_none()
-        if archive is not None:
-            archive.wallet_charge_skipped = True
-            db.add(archive)
-
-    await db.delete(tx)
+    # Keep a hidden, zero-effect tombstone for the billing_run_id. A delayed
+    # duplicate completion therefore cannot recreate this deliberately removed
+    # charge, while a later reprint of the same archive has its own run ID and
+    # remains billable.
+    tx.is_voided = True
     await db.flush()
     await db.flush()
 
 
     await _rebuild_wallet_ledger_for_user(db, user_id)
     await _rebuild_wallet_ledger_for_user(db, user_id)
@@ -532,16 +539,24 @@ async def edit_transaction(
     """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
     """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
 
 
-    result = await db.execute(select(WalletTransaction).where(WalletTransaction.id == transaction_id))
+    result = await db.execute(
+        select(WalletTransaction).where(
+            WalletTransaction.id == transaction_id,
+            WalletTransaction.is_voided.is_(False),
+        )
+    )
     tx = result.scalar_one_or_none()
     tx = result.scalar_one_or_none()
     if tx is None:
     if tx is None:
         raise HTTPException(status_code=404, detail="Transaction not found")
         raise HTTPException(status_code=404, detail="Transaction not found")
 
 
     # Apply edits
     # Apply edits
     if request.user_id is not None:
     if request.user_id is not None:
+        await _get_user_or_404(db, request.user_id)
         tx.user_id = request.user_id
         tx.user_id = request.user_id
 
 
-    if request.cost_center_id is not None:
+    if "cost_center_id" in request.model_fields_set:
+        if request.cost_center_id is not None:
+            await _get_cost_center_or_404(db, request.cost_center_id)
         tx.cost_center_id = request.cost_center_id
         tx.cost_center_id = request.cost_center_id
 
 
     if request.amount is not None:
     if request.amount is not None:
@@ -573,6 +588,8 @@ async def create_manual_print(
 ):
 ):
     """Create a manual print charge transaction (for admin purposes)."""
     """Create a manual print charge transaction (for admin purposes)."""
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, request.user_id)
+    await _get_cost_center_or_404(db, request.cost_center_id)
 
 
     from datetime import timezone
     from datetime import timezone
 
 
@@ -680,7 +697,7 @@ async def get_user_transactions(
 
 
     result = await db.execute(
     result = await db.execute(
         select(WalletTransaction)
         select(WalletTransaction)
-        .where(WalletTransaction.user_id == user_id)
+        .where(WalletTransaction.user_id == user_id, WalletTransaction.is_voided.is_(False))
         .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
         .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
         .limit(limit)
         .limit(limit)
         .offset(offset)
         .offset(offset)
@@ -857,6 +874,12 @@ async def update_cost_center(
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
     center = await _get_cost_center_or_404(db, cost_center_id)
     center = await _get_cost_center_or_404(db, cost_center_id)
 
 
+    if center.is_private and body.is_active is False:
+        raise HTTPException(
+            status_code=400,
+            detail="Private cost centers cannot be deactivated; set their budget to 0 to prevent printing",
+        )
+
     if body.name is not None:
     if body.name is not None:
         center.name = body.name.strip()
         center.name = body.name.strip()
     if body.is_active is not None:
     if body.is_active is not None:
@@ -959,18 +982,35 @@ async def delete_cost_center(
     if center.is_private:
     if center.is_private:
         raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
         raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
 
 
-    balance_map = await _get_cost_center_balance_map(db, [center.id])
-    total_balance = balance_map.get(center.id, 0.0)
-    if abs(total_balance) > 1e-9:
-        raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
+    transaction_id = await db.scalar(
+        select(WalletTransaction.id)
+        .where(
+            WalletTransaction.cost_center_id == center.id,
+            WalletTransaction.is_voided.is_(False),
+        )
+        .limit(1)
+    )
+    if transaction_id is not None:
+        # ON DELETE SET NULL would turn these shared-center entries into
+        # personal transactions and silently rewrite the affected wallets.
+        raise HTTPException(status_code=400, detail="Cost center cannot be deleted while transactions reference it")
+
+    active_reservation_id = await db.scalar(
+        select(BudgetReservation.id)
+        .where(
+            BudgetReservation.cost_center_id == center.id,
+            BudgetReservation.status == "active",
+        )
+        .limit(1)
+    )
+    if active_reservation_id is not None:
+        raise HTTPException(
+            status_code=400,
+            detail="Cost center cannot be deleted while active budget reservations reference it",
+        )
 
 
     await db.delete(center)
     await db.delete(center)
     await db.flush()
     await db.flush()
-    # ON DELETE SET NULL reclassifies the former shared-center entries as
-    # personal transactions, so synchronize affected wallets immediately.
-    from backend.app.core.database import repair_wallet_ledger_internal
-
-    await repair_wallet_ledger_internal(db)
     await db.commit()
     await db.commit()
     return {"status": "success"}
     return {"status": "success"}
 
 

+ 1 - 0
backend/app/api/routes/notification_templates.py

@@ -30,6 +30,7 @@ EVENT_NAMES = {
     "print_failed": "Print Failed",
     "print_failed": "Print Failed",
     "print_stopped": "Print Stopped",
     "print_stopped": "Print Stopped",
     "print_progress": "Print Progress",
     "print_progress": "Print Progress",
+    "billing_charge_failed": "Billing Charge Failed",
     "printer_offline": "Printer Offline",
     "printer_offline": "Printer Offline",
     "printer_error": "Printer Error",
     "printer_error": "Printer Error",
     "filament_low": "Filament Low",
     "filament_low": "Filament Low",

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -44,6 +44,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_print_stopped": provider.on_print_stopped,
         "on_print_stopped": provider.on_print_stopped,
         "on_print_progress": provider.on_print_progress,
         "on_print_progress": provider.on_print_progress,
         "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
         "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
+        "on_billing_charge_failed": provider.on_billing_charge_failed,
         # Printer status events
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
         "on_printer_error": provider.on_printer_error,
@@ -126,6 +127,7 @@ async def create_notification_provider(
         on_print_stopped=provider_data.on_print_stopped,
         on_print_stopped=provider_data.on_print_stopped,
         on_print_progress=provider_data.on_print_progress,
         on_print_progress=provider_data.on_print_progress,
         on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
         on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
+        on_billing_charge_failed=provider_data.on_billing_charge_failed,
         # Printer status events
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
         on_printer_error=provider_data.on_printer_error,

+ 82 - 58
backend/app/core/database.py

@@ -1097,6 +1097,7 @@ async def _migrate_create_finance_tables(conn) -> None:
                 print_run_id VARCHAR(100),
                 print_run_id VARCHAR(100),
                 print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
                 print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
                 print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
                 print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
+                is_voided BOOLEAN NOT NULL DEFAULT 0,
                 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
                 CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
                     transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
                     transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
@@ -1177,6 +1178,7 @@ async def _migrate_create_finance_tables(conn) -> None:
                 print_run_id VARCHAR(100),
                 print_run_id VARCHAR(100),
                 print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
                 print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
                 print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
                 print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
+                is_voided BOOLEAN NOT NULL DEFAULT FALSE,
                 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
                 CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
                     transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
                     transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
@@ -2969,6 +2971,30 @@ async def run_migrations(conn):
 
 
     # Migration: Store estimated print cost for budget checks before queued jobs start
     # Migration: Store estimated print cost for budget checks before queued jobs start
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN billing_run_id VARCHAR(36)")
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN billing_run_id VARCHAR(36)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT 0 NOT NULL")
+    else:
+        await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT FALSE NOT NULL")
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_is_voided ON wallet_transactions (is_voided)",
+    )
+    await _safe_execute(
+        conn,
+        "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT 1",
+    )
+
+    # Reprints reuse their source archive, so archive uniqueness must only be
+    # the legacy fallback for rows without a per-run UUID. The globally unique
+    # print_run_id is the idempotency key for all new charges.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS uq_wallet_transactions_archive")
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive"
+        " ON wallet_transactions (transaction_type, print_archive_id) WHERE print_run_id IS NULL",
+    )
 
 
     # Migration: Persist active budget reservations for accepted background dispatch jobs.
     # Migration: Persist active budget reservations for accepted background dispatch jobs.
     if is_sqlite():
     if is_sqlite():
@@ -4919,39 +4945,16 @@ async def seed_color_catalog():
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
 
 
 
 
-async def repair_wallet_ledger():
-    """Repair wallet ledger balance_after values.
-
-    This is called during database initialization to ensure all balance_after
-    values are correct after code changes. Fixes old balance_after values to:
-    - Personal transactions: unassigned plus the user's own private cost center
-    - Cost-center transactions: global running balance for the entire cost center
-    Also updates UserWallet.balance to the canonical personal ledger sum.
-    """
-    async with async_session() as session:
-        updated_count = await repair_wallet_ledger_internal(session)
-        await session.commit()
-
-        if updated_count > 0:
-            logger.info("Repaired wallet ledger: updated %d transactions/wallets", updated_count)
-
-
 async def repair_wallet_ledger_internal(session: AsyncSession):
 async def repair_wallet_ledger_internal(session: AsyncSession):
     """Internal helper that repairs wallet ledger using an existing session.
     """Internal helper that repairs wallet ledger using an existing session.
 
 
     Used by API endpoints that need to rebuild the ledger within their own transaction.
     Used by API endpoints that need to rebuild the ledger within their own transaction.
     """
     """
-    from sqlalchemy import select
+    from sqlalchemy import bindparam, select
 
 
     from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
     from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
     from backend.app.services.finance_balance import transaction_affects_personal_balance
     from backend.app.services.finance_balance import transaction_affects_personal_balance
 
 
-    # Get ALL transactions sorted by timestamp
-    result = await session.execute(
-        select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
-    )
-    all_transactions = result.scalars().all()
-
     center_rows = await session.execute(select(CostCenter.id, CostCenter.is_private, CostCenter.owner_user_id))
     center_rows = await session.execute(select(CostCenter.id, CostCenter.is_private, CostCenter.owner_user_id))
     centers = {
     centers = {
         int(center_id): (bool(is_private), owner_user_id) for center_id, is_private, owner_user_id in center_rows
         int(center_id): (bool(is_private), owner_user_id) for center_id, is_private, owner_user_id in center_rows
@@ -4961,41 +4964,62 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
     cc_running_balances: dict[int, float] = {}  # cost_center_id -> running balance
     cc_running_balances: dict[int, float] = {}  # cost_center_id -> running balance
     user_personal_balances: dict[int, float] = {}  # user_id -> personal running balance
     user_personal_balances: dict[int, float] = {}  # user_id -> personal running balance
 
 
-    tx_updates: list[tuple[WalletTransaction, float]] = []
-
-    for tx in all_transactions:
-        center_is_private, center_owner_user_id = centers.get(tx.cost_center_id, (False, None))
-        affects_personal = transaction_affects_personal_balance(
-            tx.user_id,
-            tx.cost_center_id,
-            is_private=center_is_private,
-            owner_user_id=center_owner_user_id,
-        )
-        if tx.cost_center_id is None:
-            # Personal transaction: per-user running balance
-            current = user_personal_balances.get(tx.user_id, 0.0)
-            new_balance = current + float(tx.amount)
-            user_personal_balances[tx.user_id] = new_balance
-            tx_updates.append((tx, new_balance))
-        else:
-            # Cost-center transaction: global running balance for this cost center
-            current = cc_running_balances.get(tx.cost_center_id, 0.0)
-            new_balance = current + float(tx.amount)
-            cc_running_balances[tx.cost_center_id] = new_balance
-            tx_updates.append((tx, new_balance))
-
-            # The owner's private cost center is also part of that user's
-            # personal wallet. Shared centers never enter this sum.
-            if affects_personal:
-                user_personal_balances[tx.user_id] = user_personal_balances.get(tx.user_id, 0.0) + float(tx.amount)
-
-    # Update all transactions with the new balance_after values
     updated_count = 0
     updated_count = 0
-    for tx, new_balance in tx_updates:
-        if tx.balance_after != new_balance:
-            tx.balance_after = new_balance
-            session.add(tx)
-            updated_count += 1
+    batch_size = 1000
+    batch_offset = 0
+    while True:
+        rows = (
+            await session.execute(
+                select(
+                    WalletTransaction.id,
+                    WalletTransaction.user_id,
+                    WalletTransaction.cost_center_id,
+                    WalletTransaction.amount,
+                    WalletTransaction.balance_after,
+                )
+                .where(WalletTransaction.is_voided.is_(False))
+                .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+                .offset(batch_offset)
+                .limit(batch_size)
+            )
+        ).all()
+        if not rows:
+            break
+
+        updates: list[dict[str, object]] = []
+        for transaction_id, user_id, cost_center_id, amount, balance_after in rows:
+            amount_value = float(amount)
+            center_is_private, center_owner_user_id = centers.get(cost_center_id, (False, None))
+            affects_personal = transaction_affects_personal_balance(
+                user_id,
+                cost_center_id,
+                is_private=center_is_private,
+                owner_user_id=center_owner_user_id,
+            )
+            if cost_center_id is None:
+                new_balance = round(user_personal_balances.get(user_id, 0.0) + amount_value, 2)
+                user_personal_balances[user_id] = new_balance
+            else:
+                new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
+                cc_running_balances[cost_center_id] = new_balance
+                if affects_personal:
+                    user_personal_balances[user_id] = round(
+                        user_personal_balances.get(user_id, 0.0) + amount_value,
+                        2,
+                    )
+
+            if balance_after is None or round(float(balance_after), 2) != new_balance:
+                updates.append({"_transaction_id": transaction_id, "_balance_after": new_balance})
+
+        if updates:
+            statement = (
+                WalletTransaction.__table__.update()
+                .where(WalletTransaction.__table__.c.id == bindparam("_transaction_id"))
+                .values(balance_after=bindparam("_balance_after"))
+            )
+            await session.execute(statement, updates)
+            updated_count += len(updates)
+        batch_offset += len(rows)
 
 
     # Update every wallet, including stale wallets whose canonical balance is
     # Update every wallet, including stale wallets whose canonical balance is
     # now zero because their last personal transaction was deleted.
     # now zero because their last personal transaction was deleted.

+ 109 - 13
backend/app/main.py

@@ -426,6 +426,12 @@ _first_layer_notified: dict[int, bool] = {}
 # Track whether we already sent a kill-switch stop for the current unauthorized print
 # Track whether we already sent a kill-switch stop for the current unauthorized print
 _unauthorized_print_kill_sent: set[int] = set()
 _unauthorized_print_kill_sent: set[int] = set()
 
 
+# The MQTT status callback is a hot path. Cache the two-setting kill-switch
+# lookup briefly so an unknown active print does not query the database on
+# every status frame. A short TTL keeps settings changes responsive.
+_KILL_SWITCH_SETTING_CACHE_TTL_SECONDS = 5.0
+_kill_switch_setting_cache: tuple[bool, float] | None = None
+
 # Provider notification started when the kill switch stops a print. The later
 # Provider notification started when the kill switch stops a print. The later
 # MQTT print-complete callback awaits this task and only sends its regular
 # MQTT print-complete callback awaits this task and only sends its regular
 # provider notification when the immediate attempt failed.
 # provider notification when the immediate attempt failed.
@@ -695,6 +701,35 @@ def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple
     return possible_keys
     return possible_keys
 
 
 
 
+def _is_bambuddy_authorized_print_in_memory(printer_id: int, state: PrinterState) -> bool:
+    """Check the cheap, process-local print ownership signals."""
+
+    if printer_manager.get_current_print_user(printer_id):
+        return True
+
+    return any(key in _expected_prints or key in _active_prints for key in _build_status_print_keys(printer_id, state))
+
+
+async def _is_printer_kill_switch_enabled_cached() -> bool:
+    """Return the kill-switch setting without querying on every MQTT frame."""
+
+    global _kill_switch_setting_cache
+
+    now = time.monotonic()
+    if _kill_switch_setting_cache is not None:
+        enabled, expires_at = _kill_switch_setting_cache
+        if now < expires_at:
+            return enabled
+
+    async with async_session() as db:
+        from backend.app.services.finance_budget import is_printer_kill_switch_enabled
+
+        enabled = await is_printer_kill_switch_enabled(db)
+
+    _kill_switch_setting_cache = (enabled, now + _KILL_SWITCH_SETTING_CACHE_TTL_SECONDS)
+    return enabled
+
+
 async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db) -> bool | None:
 async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db) -> bool | None:
     """Resolve whether the current print was started by Bambuddy.
     """Resolve whether the current print was started by Bambuddy.
 
 
@@ -703,12 +738,10 @@ async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db
     frames after a restart may arrive before all subtask fields are populated.
     frames after a restart may arrive before all subtask fields are populated.
     """
     """
 
 
-    if printer_manager.get_current_print_user(printer_id):
+    if _is_bambuddy_authorized_print_in_memory(printer_id, state):
         return True
         return True
 
 
     possible_keys = _build_status_print_keys(printer_id, state)
     possible_keys = _build_status_print_keys(printer_id, state)
-    if any(key in _expected_prints or key in _active_prints for key in possible_keys):
-        return True
 
 
     # In-memory ownership is lost on every Bambuddy restart. The archive row is
     # In-memory ownership is lost on every Bambuddy restart. The archive row is
     # the durable source of truth; subtask_id is minted per print and avoids
     # the durable source of truth; subtask_id is minted per print and avoids
@@ -1429,16 +1462,21 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     is_active_print = state.state in _ACTIVE_PRINT_STATES
     is_active_print = state.state in _ACTIVE_PRINT_STATES
     if not is_active_print:
     if not is_active_print:
         _unauthorized_print_kill_sent.discard(printer_id)
         _unauthorized_print_kill_sent.discard(printer_id)
+    elif printer_id in _unauthorized_print_kill_sent:
+        # stop_print() was already sent for this print; avoid all further
+        # ownership and settings work until the printer leaves an active state.
+        pass
+    elif _is_bambuddy_authorized_print_in_memory(printer_id, state):
+        # Normal Bambuddy-started prints stay entirely on the in-memory path.
+        _unauthorized_print_kill_sent.discard(printer_id)
     else:
     else:
         kill_switch_enabled = False
         kill_switch_enabled = False
         authorization: bool | None = None
         authorization: bool | None = None
         status_logger = logging.getLogger(__name__)
         status_logger = logging.getLogger(__name__)
         try:
         try:
-            async with async_session() as db:
-                from backend.app.services.finance_budget import is_printer_kill_switch_enabled
-
-                kill_switch_enabled = await is_printer_kill_switch_enabled(db)
-                if kill_switch_enabled:
+            kill_switch_enabled = await _is_printer_kill_switch_enabled_cached()
+            if kill_switch_enabled:
+                async with async_session() as db:
                     authorization = await _is_bambuddy_authorized_print(printer_id, state, db)
                     authorization = await _is_bambuddy_authorized_print(printer_id, state, db)
         except Exception as e:
         except Exception as e:
             # Fail safe: a database/reconciliation error must never turn into an
             # Fail safe: a database/reconciliation error must never turn into an
@@ -1456,8 +1494,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                 "[KILL SWITCH] Deferring authorization for printer %s until archive state is reconciled",
                 "[KILL SWITCH] Deferring authorization for printer %s until archive state is reconciled",
                 printer_id,
                 printer_id,
             )
             )
-        elif printer_id in _unauthorized_print_kill_sent:
-            pass
         else:
         else:
             try:
             try:
                 stopped = printer_manager.stop_print(printer_id)
                 stopped = printer_manager.stop_print(printer_id)
@@ -5244,6 +5280,10 @@ async def on_print_complete(printer_id: int, data: dict):
     # so queue items don't get stuck in "printing" when archive lookup fails.
     # so queue items don't get stuck in "printing" when archive lookup fails.
     # Uses run_with_retry to handle SQLite "database is locked" errors (#897).
     # Uses run_with_retry to handle SQLite "database is locked" errors (#897).
     queue_item_id = None
     queue_item_id = None
+    billing_run_id: str | None = None
+    billing_user_id: int | None = None
+    billing_cost_center_id: int | None = None
+    billing_plate_id: int | None = None
     queue_status = None
     queue_status = None
     queue_auto_off = False
     queue_auto_off = False
     try:
     try:
@@ -5251,6 +5291,7 @@ async def on_print_complete(printer_id: int, data: dict):
         from backend.app.models.print_queue import PrintQueueItem
         from backend.app.models.print_queue import PrintQueueItem
 
 
         async def _update_queue_status(db):
         async def _update_queue_status(db):
+            nonlocal billing_run_id, billing_user_id, billing_cost_center_id, billing_plate_id
             nonlocal queue_item_id, queue_status, queue_auto_off
             nonlocal queue_item_id, queue_status, queue_auto_off
             result = await db.execute(
             result = await db.execute(
                 select(PrintQueueItem)
                 select(PrintQueueItem)
@@ -5283,6 +5324,10 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
                 await db.commit()
                 await db.commit()
                 queue_item_id = item.id
                 queue_item_id = item.id
+                billing_run_id = item.billing_run_id
+                billing_user_id = item.created_by_id
+                billing_cost_center_id = item.cost_center_id
+                billing_plate_id = item.plate_id
                 queue_auto_off = item.auto_off_after
                 queue_auto_off = item.auto_off_after
                 logger.info("Updated queue item %s status to %s", item.id, queue_status)
                 logger.info("Updated queue item %s status to %s", item.id, queue_status)
 
 
@@ -5409,6 +5454,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     billing_planned_grams, billing_base_cost = _plate_scoped_run_estimate(
                     billing_planned_grams, billing_base_cost = _plate_scoped_run_estimate(
                         billing_archive,
                         billing_archive,
                         billing_path,
                         billing_path,
+                        billing_plate_id if billing_plate_id is not None else _get_start_plate_id(archive_id),
                     )
                     )
         except Exception as e:
         except Exception as e:
             logger.warning("[FINANCE] Failed to capture planned usage for archive %s: %s", archive_id, e)
             logger.warning("[FINANCE] Failed to capture planned usage for archive %s: %s", archive_id, e)
@@ -5650,6 +5696,8 @@ async def on_print_complete(printer_id: int, data: dict):
                 from backend.app.services.finance_billing import apply_print_charge_for_archive
                 from backend.app.services.finance_billing import apply_print_charge_for_archive
 
 
                 archive = await db.get(PrintArchive, archive_id)
                 archive = await db.get(PrintArchive, archive_id)
+                if archive and billing_run_id is None:
+                    billing_run_id = getattr(archive, "billing_run_id", None)
                 if archive and archive.created_by_id is None and _print_user_info:
                 if archive and archive.created_by_id is None and _print_user_info:
                     archive.created_by_id = _print_user_info.get("user_id")
                     archive.created_by_id = _print_user_info.get("user_id")
                     await db.flush()
                     await db.flush()
@@ -5665,12 +5713,16 @@ async def on_print_complete(printer_id: int, data: dict):
                     usage_results,
                     usage_results,
                 )
                 )
                 filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None
                 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)
+                in_memory_cost_center_id = _print_cost_center_ids.pop(archive_id, None)
                 charged = await apply_print_charge_for_archive(
                 charged = await apply_print_charge_for_archive(
                     db,
                     db,
                     archive_id,
                     archive_id,
-                    cost_center_id=cost_center_id,
-                    print_run_id=archive.subtask_id if archive else None,
+                    charged_user_id=billing_user_id,
+                    cost_center_id=(
+                        billing_cost_center_id if billing_cost_center_id is not None else in_memory_cost_center_id
+                    ),
+                    print_queue_id=queue_item_id,
+                    print_run_id=billing_run_id,
                     base_cost_override=billing_base_cost,
                     base_cost_override=billing_base_cost,
                     filament_usage=filament_usage,
                     filament_usage=filament_usage,
                 )
                 )
@@ -5679,6 +5731,50 @@ async def on_print_complete(printer_id: int, data: dict):
                     logger.info("[FINANCE] Applied print charge for archive %s", archive_id)
                     logger.info("[FINANCE] Applied print charge for archive %s", archive_id)
     except Exception as e:
     except Exception as e:
         logger.warning("[FINANCE] Failed to apply print charge for archive %s: %s", archive_id, e)
         logger.warning("[FINANCE] Failed to apply print charge for archive %s: %s", archive_id, e)
+        printer_info = printer_manager.get_printer(printer_id)
+        billing_printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
+        billing_filename = filename or subtask_name or "Unknown"
+        billing_error = str(e)
+        try:
+            await ws_manager.broadcast(
+                {
+                    "type": "billing_charge_failed",
+                    "printer_id": printer_id,
+                    "printer_name": billing_printer_name,
+                    "filename": billing_filename,
+                    "archive_id": archive_id,
+                }
+            )
+        except Exception as notification_error:
+            logger.error(
+                "[FINANCE] Failed to broadcast billing error for archive %s: %s",
+                archive_id,
+                notification_error,
+            )
+
+        async def _notify_billing_charge_failed() -> None:
+            try:
+                async with async_session() as notification_db:
+                    await notification_service.on_billing_charge_failed(
+                        printer_id,
+                        billing_printer_name,
+                        billing_filename,
+                        archive_id,
+                        billing_error,
+                        notification_db,
+                    )
+            except Exception as provider_error:
+                logger.error(
+                    "[FINANCE] Failed to send provider billing alert for archive %s: %s",
+                    archive_id,
+                    provider_error,
+                    exc_info=True,
+                )
+
+        spawn_background_task(
+            _notify_billing_charge_failed(),
+            name=f"billing-charge-failed-{archive_id}",
+        )
 
 
     log_timing("Finance charge update")
     log_timing("Finance charge update")
 
 

+ 3 - 0
backend/app/models/archive.py

@@ -74,6 +74,9 @@ class PrintArchive(Base):
     # if the same subtask_id reappears after restart, we know it's the same
     # if the same subtask_id reappears after restart, we know it's the same
     # print and keep the original row instead of cancel-then-create.
     # print and keep the original row instead of cancel-then-create.
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    # Durable Bambuddy UUID for billing idempotency. Unlike subtask_id, this is
+    # not constrained by printer firmware and is replaced for every reprint.
+    billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
 
 
     # Which plate of a multi-plate 3MF this print was for (1-based), copied from
     # Which plate of a multi-plate 3MF this print was for (1-based), copied from
     # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
     # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded

+ 5 - 0
backend/app/models/finance.py

@@ -147,6 +147,11 @@ class WalletTransaction(Base):
     print_queue_id: Mapped[int | None] = mapped_column(
     print_queue_id: Mapped[int | None] = mapped_column(
         ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
         ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
     )
     )
+    # Voided ledger rows stay persisted as run-scoped idempotency tombstones.
+    # They are excluded from balances and API listings, but their print_run_id
+    # prevents a delayed duplicate completion callback from recreating a charge
+    # that an administrator deliberately removed.
+    is_voided: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
 
 
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
 
 

+ 1 - 0
backend/app/models/notification.py

@@ -66,6 +66,7 @@ class NotificationProvider(Base):
     on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
     on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
     on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
+    on_billing_charge_failed = Column(Boolean, default=True)  # A completed/stopped print could not be charged
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
     on_printer_offline = Column(Boolean, default=False)

+ 6 - 0
backend/app/models/notification_template.py

@@ -61,6 +61,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Missing Spool Assignment",
         "title_template": "Missing Spool Assignment",
         "body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
         "body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
     },
     },
+    {
+        "event_type": "billing_charge_failed",
+        "name": "Billing Charge Failed",
+        "title_template": "Billing Charge Failed",
+        "body_template": "{printer}: {filename}\nThe print charge could not be recorded. The budget reservation was retained.\nArchive: {archive_id}",
+    },
     {
     {
         "event_type": "printer_offline",
         "event_type": "printer_offline",
         "name": "Printer Offline",
         "name": "Printer Offline",

+ 3 - 0
backend/app/models/print_queue.py

@@ -36,6 +36,9 @@ class PrintQueueItem(Base):
         ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
         ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
     )
     )
     estimated_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
     estimated_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
+    # Bambuddy-owned globally unique identity for one physical dispatch. This
+    # must not reuse the printer protocol's 31-bit subtask_id.
+    billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
     batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
 
 

+ 2 - 0
backend/app/schemas/notification.py

@@ -40,6 +40,7 @@ class NotificationProviderBase(BaseModel):
         default=False,
         default=False,
         description="Notify when a print starts with required trays missing spool assignments",
         description="Notify when a print starts with required trays missing spool assignments",
     )
     )
+    on_billing_charge_failed: bool = Field(default=True, description="Notify when a print charge cannot be recorded")
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
@@ -137,6 +138,7 @@ class NotificationProviderUpdate(BaseModel):
     on_print_stopped: bool | None = None
     on_print_stopped: bool | None = None
     on_print_progress: bool | None = None
     on_print_progress: bool | None = None
     on_print_missing_spool_assignment: bool | None = None
     on_print_missing_spool_assignment: bool | None = None
+    on_billing_charge_failed: bool | None = None
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool | None = None
     on_printer_offline: bool | None = None

+ 10 - 0
backend/app/schemas/notification_template.py

@@ -16,6 +16,7 @@ class EventType(StrEnum):
     PRINT_STOPPED = "print_stopped"
     PRINT_STOPPED = "print_stopped"
     PRINT_PROGRESS = "print_progress"
     PRINT_PROGRESS = "print_progress"
     PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
     PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
+    BILLING_CHARGE_FAILED = "billing_charge_failed"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_ERROR = "printer_error"
     PRINTER_ERROR = "printer_error"
     FILAMENT_LOW = "filament_low"
     FILAMENT_LOW = "filament_low"
@@ -71,6 +72,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "timestamp",
         "app_name",
         "app_name",
     ],
     ],
+    "billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -157,6 +159,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    "billing_charge_failed": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "archive_id": "123",
+        "error": "The transaction could not be persisted",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "Bambuddy",
+    },
     "printer_offline": {
     "printer_offline": {
         "printer": "Bambu X1C",
         "printer": "Bambu X1C",
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",

+ 4 - 1
backend/app/services/bambu_mqtt.py

@@ -3188,7 +3188,10 @@ class BambuMQTTClient:
         if "subtask_id" in data:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
         if "mc_percent" in data:
-            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
+            # Billing: retain this frame's latest positive value immediately.
+            # A display-side abort may be the very next frame (and may omit
+            # mc_percent entirely), so retaining only the previous frame can
+            # lose the only usable estimate for proportional charging.
             previous_progress = self.state.progress
             previous_progress = self.state.progress
             new_progress = float(data["mc_percent"])
             new_progress = float(data["mc_percent"])
             if new_progress > 0:
             if new_progress > 0:

+ 5 - 1
backend/app/services/finance_balance.py

@@ -36,7 +36,11 @@ async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
         select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
         select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
         .select_from(WalletTransaction)
         .select_from(WalletTransaction)
         .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
         .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
-        .where(WalletTransaction.user_id == user_id, personal_balance_condition(user_id))
+        .where(
+            WalletTransaction.user_id == user_id,
+            WalletTransaction.is_voided.is_(False),
+            personal_balance_condition(user_id),
+        )
     )
     )
     return round(float(result.scalar_one() or 0.0), 2)
     return round(float(result.scalar_one() or 0.0), 2)
 
 

+ 88 - 27
backend/app/services/finance_billing.py

@@ -1,4 +1,5 @@
 import logging
 import logging
+import uuid
 
 
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.exc import IntegrityError, SQLAlchemyError
 from sqlalchemy.exc import IntegrityError, SQLAlchemyError
@@ -12,6 +13,10 @@ from backend.app.services.finance_budget import is_billing_enabled, release_budg
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+class BillingRunIdCollisionError(RuntimeError):
+    """A billing idempotency key points at a different physical print run."""
+
+
 async def _get_balance_after_for_transaction(
 async def _get_balance_after_for_transaction(
     db: AsyncSession,
     db: AsyncSession,
     user_id: int,
     user_id: int,
@@ -43,6 +48,7 @@ async def _get_balance_after_for_transaction(
             result = await db.execute(
             result = await db.execute(
                 select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
                 select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
                     WalletTransaction.cost_center_id == cost_center_id,
                     WalletTransaction.cost_center_id == cost_center_id,
+                    WalletTransaction.is_voided.is_(False),
                 )
                 )
             )
             )
             current_balance = float(result.scalar() or 0.0)
             current_balance = float(result.scalar() or 0.0)
@@ -110,7 +116,9 @@ async def apply_print_charge_for_archive(
     db: AsyncSession,
     db: AsyncSession,
     archive_id: int,
     archive_id: int,
     *,
     *,
+    charged_user_id: int | None = None,
     cost_center_id: int | None = None,
     cost_center_id: int | None = None,
+    print_queue_id: int | None = None,
     print_run_id: str | None = None,
     print_run_id: str | None = None,
     base_cost_override: float | None = None,
     base_cost_override: float | None = None,
     filament_usage: tuple[float | None, float | None] | None = None,
     filament_usage: tuple[float | None, float | None] | None = None,
@@ -124,7 +132,12 @@ async def apply_print_charge_for_archive(
     """
     """
     try:
     try:
         if not await is_billing_enabled(db):
         if not await is_billing_enabled(db):
-            await release_budget_reservation(db, print_archive_id=archive_id, status="released")
+            if print_queue_id is not None:
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=print_queue_id, status="released"
+                )
+            else:
+                await release_budget_reservation(db, print_archive_id=archive_id, status="released")
             logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
             logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
             return False
             return False
 
 
@@ -135,6 +148,10 @@ async def apply_print_charge_for_archive(
             logger.warning(f"Archive with ID {archive_id} not found.")
             logger.warning(f"Archive with ID {archive_id} not found.")
             return False
             return False
 
 
+        effective_run_id = print_run_id or archive.billing_run_id
+        # The archive-level flag is retained only for legacy deleted charges.
+        # A new scheduler dispatch clears it while persisting its new run UUID;
+        # current deletions are represented by a voided transaction instead.
         if archive.wallet_charge_skipped:
         if archive.wallet_charge_skipped:
             logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
             logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
             return False
             return False
@@ -144,7 +161,8 @@ async def apply_print_charge_for_archive(
             logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
             logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
             return False
             return False
 
 
-        if archive.created_by_id is None:
+        actual_user_id = charged_user_id if charged_user_id is not None else archive.created_by_id
+        if actual_user_id is None:
             logger.warning(f"Archive ID {archive_id} has no creator ID.")
             logger.warning(f"Archive ID {archive_id} has no creator ID.")
             return False
             return False
 
 
@@ -153,15 +171,33 @@ async def apply_print_charge_for_archive(
             logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
             logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
             return False
             return False
 
 
-        tx_conditions = [WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value]
-        if print_run_id:
-            tx_conditions.append(WalletTransaction.print_run_id == print_run_id)
-        else:
-            tx_conditions.append(WalletTransaction.print_archive_id == archive.id)
+        # New dispatches persist a UUID before sending the printer command.
+        # Generate one here only for legacy/in-flight rows created before that
+        # migration; the locked archive row makes this fallback durable.
+        if not effective_run_id:
+            effective_run_id = str(uuid.uuid4())
+            archive.billing_run_id = effective_run_id
+
+        tx_conditions = [
+            WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value,
+            WalletTransaction.print_run_id == effective_run_id,
+        ]
 
 
         existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
         existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
         if existing_tx is not None:
         if existing_tx is not None:
+            if existing_tx.print_archive_id != archive.id:
+                logger.critical(
+                    "BILLING RUN ID COLLISION: run %s belongs to archive %s, not archive %s; charge aborted",
+                    effective_run_id,
+                    existing_tx.print_archive_id,
+                    archive.id,
+                )
+                raise BillingRunIdCollisionError(
+                    f"Billing run ID {effective_run_id} is already assigned to another archive"
+                )
             logger.info(f"Transaction already exists for archive ID {archive_id}.")
             logger.info(f"Transaction already exists for archive ID {archive_id}.")
+            if existing_tx.is_voided:
+                logger.info("Print charge for run %s was voided by an administrator.", effective_run_id)
             return False
             return False
 
 
         # Calculate charge (full for completed, partial for others)
         # Calculate charge (full for completed, partial for others)
@@ -171,62 +207,87 @@ async def apply_print_charge_for_archive(
             filament_usage=filament_usage,
             filament_usage=filament_usage,
         )
         )
         if charge <= 0:
         if charge <= 0:
-            await release_budget_reservation(db, print_archive_id=archive.id, status="released")
+            if print_queue_id is not None:
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=print_queue_id, status="released"
+                )
+            else:
+                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.")
             logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
             return False
             return False
 
 
         actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
         actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
 
 
-        wallet = (
-            await db.execute(select(UserWallet).where(UserWallet.user_id == archive.created_by_id))
-        ).scalar_one_or_none()
+        wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
         if wallet is None:
         if wallet is None:
-            wallet = UserWallet(user_id=archive.created_by_id, balance=0.0, currency="EUR")
+            wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
             db.add(wallet)
             db.add(wallet)
             await db.flush()
             await db.flush()
-            logger.info(f"Created new wallet for user ID {archive.created_by_id}.")
+            logger.info("Created new wallet for user ID %s.", actual_user_id)
 
 
         label = archive.print_name or archive.filename or f"Archive {archive.id}"
         label = archive.print_name or archive.filename or f"Archive {archive.id}"
         description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
         description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
 
 
-        balance_after = await _get_balance_after_for_transaction(
-            db, archive.created_by_id, actual_cost_center_id, -charge
-        )
+        balance_after = await _get_balance_after_for_transaction(db, actual_user_id, actual_cost_center_id, -charge)
         if balance_after is not None:
         if balance_after is not None:
             balance_after = round(float(balance_after), 2)
             balance_after = round(float(balance_after), 2)
 
 
         tx = WalletTransaction(
         tx = WalletTransaction(
-            user_id=archive.created_by_id,
+            user_id=actual_user_id,
             cost_center_id=actual_cost_center_id,
             cost_center_id=actual_cost_center_id,
             transaction_type=TransactionType.PRINT_CHARGE.value,
             transaction_type=TransactionType.PRINT_CHARGE.value,
             amount=-charge,
             amount=-charge,
             balance_after=balance_after,
             balance_after=balance_after,
             description=description,
             description=description,
             created_by_user_id=None,
             created_by_user_id=None,
-            print_run_id=print_run_id or archive.subtask_id,
+            print_run_id=effective_run_id,
             print_archive_id=archive.id,
             print_archive_id=archive.id,
+            print_queue_id=print_queue_id,
         )
         )
-        db.add(tx)
-        # Ensure the transaction is flushed to detect unique/index constraint violations
+        # Limit a concurrent deduplication conflict to a savepoint. The caller
+        # owns the outer transaction, which may already contain archive-owner
+        # backfills and other completion updates that must survive this race.
         try:
         try:
-            await db.flush()
+            async with db.begin_nested():
+                db.add(tx)
+                # Flush inside the savepoint to detect unique/index conflicts.
+                await db.flush()
         except IntegrityError as e:
         except IntegrityError as e:
-            # Another concurrent worker likely created the same transaction
-            logger.info("Transaction already exists for archive ID %s (concurrent), skipping: %s", archive_id, e)
-            await db.rollback()
-            return False
+            # Distinguish a legitimate concurrent retry of this exact run from
+            # a collision or an unrelated constraint failure. Only the former
+            # is an idempotent no-op; everything else must remain loud so the
+            # caller rolls back and the budget reservation stays active.
+            concurrent_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
+            if concurrent_tx is not None and concurrent_tx.print_archive_id == archive.id:
+                logger.info("Transaction already exists for archive ID %s (concurrent), skipping", archive_id)
+                return False
+            logger.critical(
+                "Failed to persist billing charge for archive %s and run %s: %s",
+                archive_id,
+                effective_run_id,
+                e,
+                exc_info=True,
+            )
+            if concurrent_tx is not None:
+                raise BillingRunIdCollisionError(
+                    f"Billing run ID {effective_run_id} is already assigned to another archive"
+                ) from e
+            raise
 
 
         # Rebuild from the canonical personal-ledger definition. A shared cost
         # Rebuild from the canonical personal-ledger definition. A shared cost
         # center charge must not debit the user's personal wallet.
         # center charge must not debit the user's personal wallet.
         new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
         new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
 
 
         # Consume matching budget reservations after the transaction is persisted
         # Consume matching budget reservations after the transaction is persisted
-        await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
+        if print_queue_id is not None:
+            await release_budget_reservation(db, source_type="print_queue", source_id=print_queue_id, status="consumed")
+        else:
+            await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
         logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
         logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
         return True
         return True
     except SQLAlchemyError as e:
     except SQLAlchemyError as e:
         logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
         logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
-        return False
+        raise
     except ValueError as e:
     except ValueError as e:
         logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
         logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
         return False
         return False

+ 45 - 32
backend/app/services/finance_budget.py

@@ -76,6 +76,7 @@ async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly:
     conditions = [
     conditions = [
         WalletTransaction.cost_center_id == cost_center_id,
         WalletTransaction.cost_center_id == cost_center_id,
         WalletTransaction.cost_center_id.is_not(None),
         WalletTransaction.cost_center_id.is_not(None),
+        WalletTransaction.is_voided.is_(False),
     ]
     ]
     if monthly:
     if monthly:
         conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
         conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
@@ -84,12 +85,24 @@ async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly:
     return float(result.scalar() or 0.0)
     return float(result.scalar() or 0.0)
 
 
 
 
-async def _cost_center_open_queue_reservations(
+async def get_cost_center_reserved_map(
     db: AsyncSession,
     db: AsyncSession,
-    cost_center_id: int,
+    cost_center_ids: list[int],
     *,
     *,
     exclude_queue_item_id: int | None = None,
     exclude_queue_item_id: int | None = None,
-) -> float:
+    exclude_reservation_source_type: str | None = None,
+    exclude_reservation_source_id: int | None = None,
+) -> dict[int, float]:
+    """Return active holds plus unreserved open queue estimates per cost center.
+
+    Queue items that already have an active ``print_queue`` reservation are
+    excluded from the queue sum because the reservation is their replacement,
+    not an additional hold.
+    """
+
+    if not cost_center_ids:
+        return {}
+
     active_queue_reservation = (
     active_queue_reservation = (
         select(BudgetReservation.id)
         select(BudgetReservation.id)
         .where(
         .where(
@@ -99,38 +112,41 @@ async def _cost_center_open_queue_reservations(
         )
         )
         .exists()
         .exists()
     )
     )
-    conditions = [
-        PrintQueueItem.cost_center_id == cost_center_id,
+    queue_conditions = [
+        PrintQueueItem.cost_center_id.in_(cost_center_ids),
         PrintQueueItem.status.in_(("pending", "printing")),
         PrintQueueItem.status.in_(("pending", "printing")),
         ~active_queue_reservation,
         ~active_queue_reservation,
     ]
     ]
     if exclude_queue_item_id is not None:
     if exclude_queue_item_id is not None:
-        conditions.append(PrintQueueItem.id != exclude_queue_item_id)
-
-    result = await db.execute(select(func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0)).where(*conditions))
-    return float(result.scalar() or 0.0)
+        queue_conditions.append(PrintQueueItem.id != exclude_queue_item_id)
 
 
+    queue_rows = await db.execute(
+        select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
+        .where(*queue_conditions)
+        .group_by(PrintQueueItem.cost_center_id)
+    )
+    reserved_map = {int(center_id): float(value) for center_id, value in queue_rows.all() if center_id is not None}
 
 
-async def _cost_center_active_budget_reservations(
-    db: AsyncSession,
-    cost_center_id: int,
-    *,
-    exclude_source_type: str | None = None,
-    exclude_source_id: int | None = None,
-) -> float:
-    conditions = [
-        BudgetReservation.cost_center_id == cost_center_id,
+    reservation_conditions = [
+        BudgetReservation.cost_center_id.in_(cost_center_ids),
         BudgetReservation.status == "active",
         BudgetReservation.status == "active",
     ]
     ]
-    if exclude_source_type is not None and exclude_source_id is not None:
-        conditions.append(
+    if exclude_reservation_source_type is not None and exclude_reservation_source_id is not None:
+        reservation_conditions.append(
             ~(
             ~(
-                (BudgetReservation.source_type == exclude_source_type)
-                & (BudgetReservation.source_id == exclude_source_id)
+                (BudgetReservation.source_type == exclude_reservation_source_type)
+                & (BudgetReservation.source_id == exclude_reservation_source_id)
             )
             )
         )
         )
-    result = await db.execute(select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(*conditions))
-    return float(result.scalar() or 0.0)
+    reservation_rows = await db.execute(
+        select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
+        .where(*reservation_conditions)
+        .group_by(BudgetReservation.cost_center_id)
+    )
+    for center_id, value in reservation_rows.all():
+        if center_id is not None:
+            reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
+    return reserved_map
 
 
 
 
 async def validate_print_budget(
 async def validate_print_budget(
@@ -179,17 +195,14 @@ async def validate_print_budget(
         return
         return
 
 
     used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
     used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
-    reserved = await _cost_center_open_queue_reservations(
+    reserved_map = await get_cost_center_reserved_map(
         db,
         db,
-        cost_center_id,
+        [cost_center_id],
         exclude_queue_item_id=exclude_queue_item_id,
         exclude_queue_item_id=exclude_queue_item_id,
+        exclude_reservation_source_type=exclude_reservation_source_type,
+        exclude_reservation_source_id=exclude_reservation_source_id,
     )
     )
-    reserved += await _cost_center_active_budget_reservations(
-        db,
-        cost_center_id,
-        exclude_source_type=exclude_reservation_source_type,
-        exclude_source_id=exclude_reservation_source_id,
-    )
+    reserved = reserved_map.get(cost_center_id, 0.0)
     requested = estimated_cost * max(1, quantity)
     requested = estimated_cost * max(1, quantity)
     available = float(budget_limit) - used - reserved
     available = float(budget_limit) - used - reserved
     if requested > available:
     if requested > available:

+ 10 - 3
backend/app/services/finance_defaults.py

@@ -49,9 +49,16 @@ async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
         db.add(private_center)
         db.add(private_center)
         await db.flush()
         await db.flush()
         changed = True
         changed = True
-    elif private_center.name != user.username:
-        private_center.name = user.username
-        changed = True
+    else:
+        # A private center is the billing fallback for its owner and therefore
+        # must remain active. A zero budget is the supported way to prevent
+        # printing from it.
+        if not private_center.is_active:
+            private_center.is_active = True
+            changed = True
+        if private_center.name != user.username:
+            private_center.name = user.username
+            changed = True
 
 
     membership = (
     membership = (
         await db.execute(
         await db.execute(

+ 33 - 0
backend/app/services/notification_service.py

@@ -1353,6 +1353,39 @@ class NotificationService:
             variables=variables,
             variables=variables,
         )
         )
 
 
+    async def on_billing_charge_failed(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        archive_id: int | None,
+        error: str,
+        db: AsyncSession,
+    ) -> None:
+        """Notify providers that a terminal print could not be charged."""
+        providers = await self._get_providers_for_event(db, "on_billing_charge_failed", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "archive_id": str(archive_id) if archive_id is not None else "Unknown",
+            "error": error,
+        }
+        title, message = await self._build_message_from_template(db, "billing_charge_failed", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "billing_charge_failed",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)

+ 12 - 2
backend/app/services/print_cost_estimate.py

@@ -99,8 +99,18 @@ async def estimate_queue_source_cost(
 
 
     if archive is not None:
     if archive is not None:
         archive_path = settings.base_dir / archive.file_path
         archive_path = settings.base_dir / archive.file_path
-        _grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
-        return float(cost) if cost is not None and cost > 0 else None
+        grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
+        if cost is not None and cost > 0:
+            return float(cost)
+        # Older archives and imports can have trustworthy filament usage but
+        # no stored cost. Model-based and multi-printer jobs have no single
+        # spool mapping at enqueue time, so use the server setting rather than
+        # requiring the browser to provide an estimate.
+        if grams is None or grams <= 0:
+            return None
+        default_cost = await _default_cost_per_kg(db)
+        estimated_cost = (grams / 1000.0) * default_cost
+        return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
 
 
     if library_file is None:
     if library_file is None:
         return None
         return None

+ 38 - 26
backend/app/services/print_scheduler.py

@@ -4,6 +4,7 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import time
 import time
+import uuid
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
@@ -1297,7 +1298,7 @@ class PrintScheduler:
                 # reservation survives only after start_print() accepted the
                 # reservation survives only after start_print() accepted the
                 # command. Failure, cancellation, deferral, and exceptions all
                 # command. Failure, cancellation, deferral, and exceptions all
                 # release it here.
                 # release it here.
-                await asyncio.shield(self._release_unconfirmed_budget_reservation(item_db, item_id))
+                await asyncio.shield(self._release_unconfirmed_budget_reservation(item_id))
                 # Release the claim on every exit. Once dispatch has finished the
                 # Release the claim on every exit. Once dispatch has finished the
                 # row's status carries the lock (printing/failed/cancelled are all
                 # row's status carries the lock (printing/failed/cancelled are all
                 # != pending), so the token is only needed for the duration of the
                 # != pending), so the token is only needed for the duration of the
@@ -1329,37 +1330,37 @@ class PrintScheduler:
                 exc_info=True,
                 exc_info=True,
             )
             )
 
 
-    async def _release_unconfirmed_budget_reservation(self, db: AsyncSession, item_id: int) -> None:
-        """Release a queue reservation when dispatch ended before MQTT send."""
+    async def _release_unconfirmed_budget_reservation(self, item_id: int) -> None:
+        """Release a queue reservation without touching the dispatch session."""
         if item_id not in self._unconfirmed_budget_reservations:
         if item_id not in self._unconfirmed_budget_reservations:
             return
             return
 
 
         for attempt in range(1, 4):
         for attempt in range(1, 4):
-            try:
-                await db.rollback()
-                await release_budget_reservation(
-                    db,
-                    source_type="print_queue",
-                    source_id=item_id,
-                    status="released",
-                )
-                await db.commit()
-                self._unconfirmed_budget_reservations.discard(item_id)
-                return
-            except Exception as exc:
+            async with async_session() as cleanup_db:
                 try:
                 try:
-                    await db.rollback()
-                except Exception:
-                    pass
-                if attempt == 3:
-                    logger.error(
-                        "Queue item %s: failed to release budget reservation after %d attempts: %s",
-                        item_id,
-                        attempt,
-                        exc,
+                    await release_budget_reservation(
+                        cleanup_db,
+                        source_type="print_queue",
+                        source_id=item_id,
+                        status="released",
                     )
                     )
+                    await cleanup_db.commit()
+                    self._unconfirmed_budget_reservations.discard(item_id)
                     return
                     return
-                await asyncio.sleep(0.5 * attempt)
+                except Exception as exc:
+                    try:
+                        await cleanup_db.rollback()
+                    except Exception:
+                        pass
+                    if attempt == 3:
+                        logger.error(
+                            "Queue item %s: failed to release budget reservation after %d attempts: %s",
+                            item_id,
+                            attempt,
+                            exc,
+                        )
+                        return
+                    await asyncio.sleep(0.5 * attempt)
 
 
     async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
     async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
@@ -4203,11 +4204,12 @@ class PrintScheduler:
         # rowcount==0 means the user won the race; bail out, best-effort delete
         # rowcount==0 means the user won the race; bail out, best-effort delete
         # the file we just uploaded, do NOT send start_print.
         # the file we just uploaded, do NOT send start_print.
         now_utc = datetime.now(timezone.utc)
         now_utc = datetime.now(timezone.utc)
+        billing_run_id = str(uuid.uuid4())
         cas = await db.execute(
         cas = await db.execute(
             update(PrintQueueItem)
             update(PrintQueueItem)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.status == "pending")
             .where(PrintQueueItem.status == "pending")
-            .values(status="printing", started_at=now_utc)
+            .values(status="printing", started_at=now_utc, billing_run_id=billing_run_id)
         )
         )
         await db.commit()
         await db.commit()
         if cas.rowcount == 0:
         if cas.rowcount == 0:
@@ -4244,6 +4246,16 @@ class PrintScheduler:
         # item.started_at sees the values we just persisted.
         # item.started_at sees the values we just persisted.
         item.status = "printing"
         item.status = "printing"
         item.started_at = now_utc
         item.started_at = now_utc
+        item.billing_run_id = billing_run_id
+        if archive is not None:
+            archive.billing_run_id = billing_run_id
+            # Legacy transaction deletion used an archive-wide skip flag.
+            # A newly dispatched run has its own UUID/tombstone, so it must be
+            # billable independently of any older deleted run on this archive.
+            archive.wallet_charge_skipped = False
+            # Persist before MQTT send so completion and restart recovery can
+            # always recover the internal billing identity.
+            await db.commit()
 
 
         for cleanup_path in cleanup_disk_paths:
         for cleanup_path in cleanup_disk_paths:
             try:
             try:

+ 1 - 0
backend/tests/conftest.py

@@ -525,6 +525,7 @@ def notification_provider_factory(db_session):
             "on_print_stopped": True,
             "on_print_stopped": True,
             "on_print_progress": False,
             "on_print_progress": False,
             "on_print_missing_spool_assignment": False,
             "on_print_missing_spool_assignment": False,
+            "on_billing_charge_failed": True,
             "on_printer_offline": False,
             "on_printer_offline": False,
             "on_printer_error": False,
             "on_printer_error": False,
             "on_filament_low": False,
             "on_filament_low": False,

+ 247 - 4
backend/tests/integration/test_finance_api.py

@@ -6,8 +6,9 @@ from sqlalchemy import select
 
 
 from backend.app.core.auth import get_password_hash
 from backend.app.core.auth import get_password_hash
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
-from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
 from backend.app.models.group import Group
 from backend.app.models.group import Group
+from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.finance_billing import apply_print_charge_for_archive
 from backend.app.services.finance_billing import apply_print_charge_for_archive
@@ -136,6 +137,174 @@ class TestFinanceAPI:
         assert mine_after_remove.status_code == 200
         assert mine_after_remove.status_code == 200
         assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
         assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_private_cost_center_cannot_be_deactivated_but_can_have_zero_budget(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        created_user = await self._create_user_via_api(async_client, auth_headers, "private-budget-user")
+        private_center = await db_session.scalar(
+            select(CostCenter).where(
+                CostCenter.owner_user_id == created_user["id"],
+                CostCenter.is_private.is_(True),
+            )
+        )
+        assert private_center is not None
+
+        deactivate_response = await async_client.patch(
+            f"/api/v1/finance/cost-centers/{private_center.id}",
+            json={"is_active": False},
+            headers=auth_headers,
+        )
+
+        assert deactivate_response.status_code == 400
+        assert "cannot be deactivated" in deactivate_response.json()["detail"]
+        await db_session.refresh(private_center)
+        assert private_center.is_active is True
+
+        budget_response = await async_client.patch(
+            f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
+            json={"total_budget": 0},
+            headers=auth_headers,
+        )
+
+        assert budget_response.status_code == 200
+        assert budget_response.json()["total_budget"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cost_center_available_budget_does_not_double_count_queue_reservation(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        center_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={"name": "Reserved Once", "total_budget": 10.0},
+            headers=auth_headers,
+        )
+        assert center_response.status_code == 200
+        center_id = center_response.json()["id"]
+
+        reserved_item = PrintQueueItem(
+            cost_center_id=center_id,
+            estimated_cost=3.0,
+            status="pending",
+            position=1,
+        )
+        legacy_unreserved_item = PrintQueueItem(
+            cost_center_id=center_id,
+            estimated_cost=2.0,
+            status="pending",
+            position=2,
+        )
+        db_session.add_all([reserved_item, legacy_unreserved_item])
+        await db_session.flush()
+        db_session.add(
+            BudgetReservation(
+                cost_center_id=center_id,
+                amount=3.0,
+                status="active",
+                source_type="print_queue",
+                source_id=reserved_item.id,
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/finance/cost-centers", headers=auth_headers)
+
+        assert response.status_code == 200
+        center = next(item for item in response.json() if item["id"] == center_id)
+        # 3.00 active reservation + 2.00 legacy queue estimate, not 3 + 3 + 2.
+        assert center["budget_available"] == 5.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cost_center_with_balanced_transactions_cannot_be_deleted(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        admin_user,
+        db_session,
+    ):
+        center_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={"name": "Balanced History"},
+            headers=auth_headers,
+        )
+        center_id = center_response.json()["id"]
+        db_session.add_all(
+            [
+                WalletTransaction(
+                    user_id=admin_user.id,
+                    cost_center_id=center_id,
+                    transaction_type="deposit",
+                    amount=50.0,
+                    balance_after=50.0,
+                ),
+                WalletTransaction(
+                    user_id=admin_user.id,
+                    cost_center_id=center_id,
+                    transaction_type="withdraw",
+                    amount=-50.0,
+                    balance_after=0.0,
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        response = await async_client.delete(
+            f"/api/v1/finance/cost-centers/{center_id}",
+            headers=auth_headers,
+        )
+
+        assert response.status_code == 400
+        assert "transactions reference it" in response.json()["detail"]
+        transactions = (
+            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.cost_center_id == center_id)))
+            .scalars()
+            .all()
+        )
+        assert len(transactions) == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cost_center_with_active_reservation_cannot_be_deleted(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        center_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={"name": "Active Hold"},
+            headers=auth_headers,
+        )
+        center_id = center_response.json()["id"]
+        db_session.add(
+            BudgetReservation(
+                cost_center_id=center_id,
+                amount=3.0,
+                status="active",
+                source_type="direct_print",
+                source_id=123,
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.delete(
+            f"/api/v1/finance/cost-centers/{center_id}",
+            headers=auth_headers,
+        )
+
+        assert response.status_code == 400
+        assert "active budget reservations" in response.json()["detail"]
+        assert await db_session.get(CostCenter, center_id) is not None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_wallet_adjustments_and_transaction_ledger_rebuild(
     async def test_wallet_adjustments_and_transaction_ledger_rebuild(
@@ -363,6 +532,7 @@ class TestFinanceAPI:
         created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
         created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
         user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
         user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
         assert user is not None
         assert user is not None
+        user_id = user.id
 
 
         archive = PrintArchive(
         archive = PrintArchive(
             printer_id=None,
             printer_id=None,
@@ -384,13 +554,15 @@ class TestFinanceAPI:
             balance_after=-4.0,
             balance_after=-4.0,
             description="Print charge: print.gcode",
             description="Print charge: print.gcode",
             created_by_user_id=None,
             created_by_user_id=None,
+            print_run_id="deleted-print-run",
             print_archive_id=archive.id,
             print_archive_id=archive.id,
         )
         )
         db_session.add(tx)
         db_session.add(tx)
         await db_session.commit()
         await db_session.commit()
+        archive_id = archive.id
 
 
         tx_rows_before = (
         tx_rows_before = (
-            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
+            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
             .scalars()
             .scalars()
             .all()
             .all()
         )
         )
@@ -400,13 +572,43 @@ class TestFinanceAPI:
             f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
             f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
         )
         )
         assert delete_response.status_code == 200
         assert delete_response.status_code == 200
+        db_session.expire_all()
 
 
         tx_rows_after = (
         tx_rows_after = (
-            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
+            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
             .scalars()
             .scalars()
             .all()
             .all()
         )
         )
-        assert tx_rows_after == []
+        assert len(tx_rows_after) == 1
+        assert tx_rows_after[0].is_voided is True
+
+        # The voided run remains an idempotency tombstone and cannot be
+        # recreated by a delayed duplicate completion callback.
+        assert (
+            await apply_print_charge_for_archive(
+                db_session,
+                archive_id,
+                print_run_id="deleted-print-run",
+            )
+        ) is False
+
+        # A later reprint of the same archive has a distinct run identity and
+        # must still be charged normally.
+        assert (
+            await apply_print_charge_for_archive(
+                db_session,
+                archive_id,
+                charged_user_id=user_id,
+                print_run_id="later-reprint-run",
+            )
+        ) is True
+        await db_session.commit()
+        visible = await async_client.get(
+            f"/api/v1/finance/users/{user_id}/transactions",
+            headers=auth_headers,
+        )
+        assert visible.status_code == 200
+        assert [row["print_run_id"] for row in visible.json()] == ["later-reprint-run"]
 
 
     async def test_edit_transaction_updates_ledger(
     async def test_edit_transaction_updates_ledger(
         self,
         self,
@@ -470,6 +672,31 @@ class TestFinanceAPI:
         # Description should have "(Admin edit)" appended
         # Description should have "(Admin edit)" appended
         assert "(Admin edit)" in edited_tx_data["description"]
         assert "(Admin edit)" in edited_tx_data["description"]
 
 
+        # An explicit null moves the transaction back to the personal ledger.
+        clear_response = await async_client.patch(
+            f"/api/v1/finance/transactions/{tx_id}",
+            json={"cost_center_id": None},
+            headers=auth_headers,
+        )
+        assert clear_response.status_code == 200
+        assert clear_response.json()["cost_center_id"] is None
+
+        invalid_user_response = await async_client.patch(
+            f"/api/v1/finance/transactions/{tx_id}",
+            json={"user_id": 2147483647},
+            headers=auth_headers,
+        )
+        assert invalid_user_response.status_code == 404
+        assert invalid_user_response.json()["detail"] == "User not found"
+
+        invalid_center_response = await async_client.patch(
+            f"/api/v1/finance/transactions/{tx_id}",
+            json={"cost_center_id": 2147483647},
+            headers=auth_headers,
+        )
+        assert invalid_center_response.status_code == 404
+        assert invalid_center_response.json()["detail"] == "Cost center not found"
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_create_manual_print_and_recalculates_ledger(
     async def test_create_manual_print_and_recalculates_ledger(
@@ -514,6 +741,22 @@ class TestFinanceAPI:
         # The response includes the computed running balance for the transaction
         # The response includes the computed running balance for the transaction
         assert resp_json.get("balance_after") == -4.0
         assert resp_json.get("balance_after") == -4.0
 
 
+        invalid_user_response = await async_client.post(
+            "/api/v1/finance/transactions/manual",
+            json={**payload, "user_id": 2147483647},
+            headers=auth_headers,
+        )
+        assert invalid_user_response.status_code == 404
+        assert invalid_user_response.json()["detail"] == "User not found"
+
+        invalid_center_response = await async_client.post(
+            "/api/v1/finance/transactions/manual",
+            json={**payload, "cost_center_id": 2147483647},
+            headers=auth_headers,
+        )
+        assert invalid_center_response.status_code == 404
+        assert invalid_center_response.json()["detail"] == "Cost center not found"
+
 
 
 class TestPartialPrintChargesIntegration:
 class TestPartialPrintChargesIntegration:
     """Integration tests for partial print charge calculation."""
     """Integration tests for partial print charge calculation."""

+ 19 - 0
backend/tests/integration/test_notifications_api.py

@@ -427,6 +427,25 @@ class TestNotificationsAPI:
         response = await async_client.get(f"/api/v1/notifications/{provider.id}")
         response = await async_client.get(f"/api/v1/notifications/{provider.id}")
         assert response.json()["on_print_missing_spool_assignment"] is True
         assert response.json()["on_print_missing_spool_assignment"] is True
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_billing_charge_failed_toggle(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Billing alerts can be enabled independently for each provider."""
+        provider = await notification_provider_factory(on_billing_charge_failed=True)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_billing_charge_failed": False},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_billing_charge_failed"] is False
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_billing_charge_failed"] is False
+
 
 
 class TestNotificationTemplatesAPI:
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""
     """Integration tests for /api/v1/notification-templates/ endpoints."""

+ 27 - 0
backend/tests/integration/test_print_queue_api.py

@@ -199,6 +199,33 @@ class TestPrintQueueAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["estimated_cost"] == 1.25
         assert response.json()["estimated_cost"] == 1.25
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_model_based_queue_item_derives_cost_without_client_estimate(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Model dispatch has no printer-side estimate but remains billable."""
+        await enable_billing(db_session)
+        await printer_factory(model="X1C")
+        archive = await archive_factory(cost=1.25, filament_used_grams=50.0, sliced_for_model="X1C")
+        cost_center = CostCenter(name="Model Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
+        db_session.add(cost_center)
+        await db_session.commit()
+        await db_session.refresh(cost_center)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "target_model": "X1C",
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+            },
+        )
+
+        assert response.status_code == 200
+        assert response.json()["printer_id"] is None
+        assert response.json()["estimated_cost"] == 1.25
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_rejects_tampered_client_cost_when_server_cost_exceeds_budget(
     async def test_add_to_queue_rejects_tampered_client_cost_when_server_cost_exceeds_budget(

+ 73 - 1
backend/tests/integration/test_scheduler_budget_reservation.py

@@ -14,7 +14,7 @@ import backend.app.models  # noqa: F401 - populate Base.metadata
 import backend.app.services.print_scheduler as scheduler_module
 import backend.app.services.print_scheduler as scheduler_module
 from backend.app.core.database import Base
 from backend.app.core.database import Base
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
-from backend.app.models.finance import BudgetReservation, CostCenter
+from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -153,6 +153,15 @@ async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billin
     assert reservation.print_archive_id == billing_dispatch_case.ids.archive_id
     assert reservation.print_archive_id == billing_dispatch_case.ids.archive_id
     start_print.assert_called_once()
     start_print.assert_called_once()
 
 
+    async with billing_dispatch_case.session_maker() as db:
+        item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
+        archive = await db.get(PrintArchive, billing_dispatch_case.ids.archive_id)
+        assert item.billing_run_id is not None
+        assert archive.billing_run_id == item.billing_run_id
+        # The internal UUID is deliberately independent from Bambu's 31-bit
+        # task/subtask identifier.
+        assert len(item.billing_run_id) == 36
+
     # The printing queue row and its persisted reservation represent the same
     # The printing queue row and its persisted reservation represent the same
     # €4 hold. A second €6 job must fit exactly; €6.01 must not.
     # €4 hold. A second €6 job must fit exactly; €6.01 must not.
     async with billing_dispatch_case.session_maker() as db:
     async with billing_dispatch_case.session_maker() as db:
@@ -184,6 +193,26 @@ async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billin
             )
             )
 
 
 
 
+@pytest.mark.asyncio
+async def test_cost_center_without_budget_is_unlimited_regardless_of_wallet_balance(billing_dispatch_case):
+    """Wallet balance is accounting data; only an explicit cost-center budget gates printing."""
+    async with billing_dispatch_case.session_maker() as db:
+        user = await db.get(User, billing_dispatch_case.ids.user_id)
+        center = await db.get(CostCenter, billing_dispatch_case.ids.cost_center_id)
+        center.monthly_budget = None
+        center.total_budget = None
+        wallet = UserWallet(user_id=user.id, balance=-100.0, currency="EUR")
+        db.add(wallet)
+        await db.commit()
+
+        await validate_print_budget(
+            db,
+            cost_center_id=center.id,
+            estimated_cost=1_000_000.0,
+            current_user=user,
+        )
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_upload_failure_releases_scheduler_reservation(billing_dispatch_case):
 async def test_upload_failure_releases_scheduler_reservation(billing_dispatch_case):
     start_print = await _dispatch(billing_dispatch_case, uploaded=False)
     start_print = await _dispatch(billing_dispatch_case, uploaded=False)
@@ -244,3 +273,46 @@ async def test_cancel_during_upload_releases_scheduler_reservation(billing_dispa
         )
         )
     assert item.status == "cancelled"
     assert item.status == "cancelled"
     assert active_count == 0
     assert active_count == 0
+
+
+@pytest.mark.asyncio
+async def test_cleanup_session_does_not_rollback_failed_dispatch_status(billing_dispatch_case):
+    scheduler = PrintScheduler()
+
+    async def fail_after_reserving(db, item):
+        db.add(
+            BudgetReservation(
+                cost_center_id=item.cost_center_id,
+                amount=4.0,
+                status="active",
+                source_type="print_queue",
+                source_id=item.id,
+                print_archive_id=item.archive_id,
+            )
+        )
+        await db.commit()
+        scheduler._unconfirmed_budget_reservations.add(item.id)
+        item.status = "failed"
+        item.error_message = "dispatch failed after reservation"
+        raise RuntimeError("simulated dispatch failure")
+
+    with (
+        patch.object(scheduler_module, "async_session", billing_dispatch_case.session_maker),
+        patch.object(scheduler, "_start_print", fail_after_reserving),
+        pytest.raises(RuntimeError, match="simulated dispatch failure"),
+    ):
+        await scheduler._dispatch_one(billing_dispatch_case.ids.item_id)
+
+    async with billing_dispatch_case.session_maker() as db:
+        item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
+        reservation = await db.scalar(
+            select(BudgetReservation).where(
+                BudgetReservation.source_type == "print_queue",
+                BudgetReservation.source_id == billing_dispatch_case.ids.item_id,
+            )
+        )
+
+    assert item.status == "failed"
+    assert item.error_message == "dispatch failed after reservation"
+    assert item.dispatching_at is None
+    assert reservation.status == "released"

+ 24 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -6672,6 +6672,30 @@ class TestNoLastLayerFinishPhotoTrigger:
         assert events == []
         assert events == []
 
 
 
 
+class TestBillingProgressTracking:
+    """The latest positive MQTT progress is the fallback for partial billing."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_current_frame_is_retained_and_zero_reset_does_not_overwrite_it(self, mqtt_client):
+        """Even the first positive frame must survive an immediate printer abort."""
+        mqtt_client._process_message({"print": {"mc_percent": 25}})
+
+        assert mqtt_client._last_valid_progress == 25
+
+        mqtt_client._process_message({"print": {"mc_percent": 0}})
+
+        assert mqtt_client._last_valid_progress == 25
+
+
 class TestPrintProgressCallback:
 class TestPrintProgressCallback:
     """#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
     """#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
 
 

+ 186 - 1
backend/tests/unit/services/test_finance_service_billing.py

@@ -1,13 +1,17 @@
 """Unit tests for billing charges applied to print archives."""
 """Unit tests for billing charges applied to print archives."""
 
 
+from unittest.mock import AsyncMock
+
 import pytest
 import pytest
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
 
 
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
 from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
+from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.services.finance_billing import apply_print_charge_for_archive
+from backend.app.services.finance_billing import BillingRunIdCollisionError, apply_print_charge_for_archive
 
 
 
 
 async def enable_billing(db_session):
 async def enable_billing(db_session):
@@ -20,6 +24,187 @@ async def enable_billing(db_session):
 
 
 
 
 class TestFinanceBilling:
 class TestFinanceBilling:
+    @pytest.mark.asyncio
+    async def test_run_context_charges_initiator_and_consumes_only_its_reservation(self, db_session):
+        """Concurrent reprints of one archive keep owner, center and hold run-scoped."""
+        await enable_billing(db_session)
+        archive_owner = User(username="archive_owner", role="user", is_active=True)
+        first_user = User(username="first_reprinter", role="user", is_active=True)
+        second_user = User(username="second_reprinter", role="user", is_active=True)
+        first_center = CostCenter(name="First run CC", is_active=True, is_private=False)
+        second_center = CostCenter(name="Second run CC", is_active=True, is_private=False)
+        db_session.add_all([archive_owner, first_user, second_user, first_center, second_center])
+        await db_session.flush()
+        archive = PrintArchive(
+            filename="shared-source.3mf",
+            file_path="archives/test/shared-source.3mf",
+            file_size=123,
+            content_hash="shared-source-runs",
+            status="completed",
+            cost=4.0,
+            created_by_id=archive_owner.id,
+        )
+        db_session.add(archive)
+        await db_session.flush()
+        first_item = PrintQueueItem(
+            archive_id=archive.id,
+            cost_center_id=first_center.id,
+            estimated_cost=4.0,
+            position=1,
+            status="printing",
+            created_by_id=first_user.id,
+            billing_run_id="first-reprint-run",
+            plate_id=1,
+        )
+        second_item = PrintQueueItem(
+            archive_id=archive.id,
+            cost_center_id=second_center.id,
+            estimated_cost=4.0,
+            position=1,
+            status="printing",
+            created_by_id=second_user.id,
+            billing_run_id="second-reprint-run",
+            plate_id=2,
+        )
+        db_session.add_all([first_item, second_item])
+        await db_session.flush()
+        first_reservation = BudgetReservation(
+            cost_center_id=first_center.id,
+            amount=4.0,
+            status="active",
+            source_type="print_queue",
+            source_id=first_item.id,
+            print_archive_id=archive.id,
+        )
+        second_reservation = BudgetReservation(
+            cost_center_id=second_center.id,
+            amount=4.0,
+            status="active",
+            source_type="print_queue",
+            source_id=second_item.id,
+            print_archive_id=archive.id,
+        )
+        db_session.add_all([first_reservation, second_reservation])
+        await db_session.commit()
+
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            charged_user_id=first_user.id,
+            cost_center_id=first_center.id,
+            print_queue_id=first_item.id,
+            print_run_id=first_item.billing_run_id,
+        )
+        await db_session.commit()
+
+        assert changed is True
+        tx = await db_session.scalar(
+            select(WalletTransaction).where(WalletTransaction.print_run_id == first_item.billing_run_id)
+        )
+        assert tx is not None
+        assert tx.user_id == first_user.id
+        assert tx.user_id != archive_owner.id
+        assert tx.cost_center_id == first_center.id
+        assert tx.print_queue_id == first_item.id
+        await db_session.refresh(first_reservation)
+        await db_session.refresh(second_reservation)
+        assert first_reservation.status == "consumed"
+        assert second_reservation.status == "active"
+
+    @pytest.mark.asyncio
+    async def test_run_id_collision_with_another_archive_is_loud(self, db_session):
+        await enable_billing(db_session)
+        user = User(username="collision", role="user", is_active=True)
+        db_session.add(user)
+        await db_session.flush()
+        first = PrintArchive(
+            filename="first.3mf",
+            file_path="archives/test/first.3mf",
+            file_size=123,
+            content_hash="collision-first",
+            status="completed",
+            cost=2.0,
+            created_by_id=user.id,
+            billing_run_id="same-run-id",
+        )
+        second = PrintArchive(
+            filename="second.3mf",
+            file_path="archives/test/second.3mf",
+            file_size=123,
+            content_hash="collision-second",
+            status="completed",
+            cost=3.0,
+            created_by_id=user.id,
+            billing_run_id="same-run-id",
+        )
+        db_session.add_all([first, second])
+        await db_session.commit()
+
+        assert await apply_print_charge_for_archive(db_session, first.id, print_run_id="same-run-id") is True
+        await db_session.commit()
+
+        with pytest.raises(BillingRunIdCollisionError, match="already assigned to another archive"):
+            await apply_print_charge_for_archive(db_session, second.id, print_run_id="same-run-id")
+
+        transactions = (
+            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.print_run_id == "same-run-id")))
+            .scalars()
+            .all()
+        )
+        assert len(transactions) == 1
+        assert transactions[0].print_archive_id == first.id
+
+    @pytest.mark.asyncio
+    async def test_concurrent_charge_conflict_preserves_callers_pending_changes(self, db_session, monkeypatch):
+        await enable_billing(db_session)
+        user = User(username="concurrent_charge", role="user", is_active=True)
+        archive = PrintArchive(
+            filename="concurrent.3mf",
+            file_path="archives/test/concurrent.3mf",
+            file_size=123,
+            content_hash="concurrent-charge",
+            status="completed",
+            cost=2.0,
+            created_by_id=None,
+        )
+        db_session.add_all([user, archive])
+        await db_session.commit()
+        archive_id = archive.id
+        user_id = user.id
+
+        # Mirrors on_print_complete's owner backfill immediately before it
+        # hands the still-open session to the billing service.
+        archive.created_by_id = user_id
+        original_flush = db_session.flush
+        original_rollback = db_session.rollback
+
+        async def conflict_on_transaction_flush(objects=None):
+            if any(isinstance(obj, WalletTransaction) for obj in db_session.new):
+                raise IntegrityError("duplicate print charge", {}, Exception("unique violation"))
+            return await original_flush(objects)
+
+        rollback = AsyncMock()
+        monkeypatch.setattr(db_session, "flush", conflict_on_transaction_flush)
+        monkeypatch.setattr(db_session, "rollback", rollback)
+
+        with pytest.raises(IntegrityError, match="unique violation"):
+            await apply_print_charge_for_archive(db_session, archive_id, print_run_id="concurrent-run")
+
+        rollback.assert_not_awaited()
+
+        # Restore normal session methods so the caller can commit its own work.
+        monkeypatch.setattr(db_session, "flush", original_flush)
+        monkeypatch.setattr(db_session, "rollback", original_rollback)
+        await db_session.commit()
+        db_session.expire_all()
+
+        persisted_archive = await db_session.get(PrintArchive, archive_id)
+        assert persisted_archive.created_by_id == user_id
+        assert (
+            await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "concurrent-run"))
+            is None
+        )
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_apply_print_charge_uses_print_run_id_and_cost_center_override(self, db_session):
     async def test_apply_print_charge_uses_print_run_id_and_cost_center_override(self, db_session):
         await enable_billing(db_session)
         await enable_billing(db_session)

+ 20 - 0
backend/tests/unit/services/test_finance_service_defaults.py

@@ -70,3 +70,23 @@ class TestFinanceDefaults:
 
 
         idempotent_changed = await ensure_user_finance_defaults(db_session, user)
         idempotent_changed = await ensure_user_finance_defaults(db_session, user)
         assert idempotent_changed is False
         assert idempotent_changed is False
+
+    @pytest.mark.asyncio
+    async def test_reactivates_existing_private_center(self, db_session):
+        user = User(username="carol", role="user", is_active=True)
+        db_session.add(user)
+        await db_session.flush()
+        center = CostCenter(
+            name=user.username,
+            is_active=False,
+            is_private=True,
+            owner_user_id=user.id,
+        )
+        db_session.add(center)
+        await db_session.commit()
+
+        changed = await ensure_user_finance_defaults(db_session, user)
+        await db_session.commit()
+
+        assert changed is True
+        assert center.is_active is True

+ 34 - 0
backend/tests/unit/services/test_notification_service.py

@@ -97,6 +97,40 @@ class TestNotificationService:
 
 
             mock_send.assert_not_called()
             mock_send.assert_not_called()
 
 
+    @pytest.mark.asyncio
+    async def test_billing_charge_failure_uses_provider_event(self, service, mock_provider, mock_db):
+        """A failed charge is routed to providers that enabled the billing event."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Billing Charge Failed", "The reservation was retained")
+
+            await service.on_billing_charge_failed(
+                printer_id=7,
+                printer_name="Printer B",
+                filename="paid-job.3mf",
+                archive_id=42,
+                error="unique constraint",
+                db=mock_db,
+            )
+
+            mock_get.assert_awaited_once_with(mock_db, "on_billing_charge_failed", 7)
+            mock_build.assert_awaited_once_with(
+                mock_db,
+                "billing_charge_failed",
+                {
+                    "printer": "Printer B",
+                    "filename": "paid-job",
+                    "archive_id": "42",
+                    "error": "unique constraint",
+                },
+            )
+            assert mock_send.await_args.args[4:7] == ("billing_charge_failed", 7, "Printer B")
+            assert mock_send.await_args.kwargs["force_immediate"] is True
+
     # ========================================================================
     # ========================================================================
     # Tests for on_print_complete (status routing)
     # Tests for on_print_complete (status routing)
     # ========================================================================
     # ========================================================================

+ 16 - 0
backend/tests/unit/services/test_print_cost_estimate.py

@@ -13,6 +13,22 @@ def library_file(tmp_path):
     return SimpleNamespace(file_path=str(path), file_metadata={})
     return SimpleNamespace(file_path=str(path), file_metadata={})
 
 
 
 
+@pytest.mark.asyncio
+async def test_archive_without_stored_cost_uses_server_default(monkeypatch):
+    archive = SimpleNamespace(
+        id=7,
+        file_path="missing.gcode.3mf",
+        plate_id=None,
+        filament_used_grams=100.0,
+        cost=None,
+    )
+    monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
+
+    cost = await print_cost_estimate.estimate_queue_source_cost(SimpleNamespace(), archive=archive)
+
+    assert cost == 2.0
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_library_estimate_uses_server_default_cost(monkeypatch, library_file):
 async def test_library_estimate_uses_server_default_cost(monkeypatch, library_file):
     monkeypatch.setattr(
     monkeypatch.setattr(

+ 36 - 0
backend/tests/unit/test_billing_run_id_migration.py

@@ -0,0 +1,36 @@
+"""Migration coverage for durable per-dispatch billing identities."""
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.models.external_link  # noqa: F401 - required by a legacy ALTER in run_migrations
+import backend.app.models.print_log  # noqa: F401 - required by a legacy ALTER in run_migrations
+from backend.app.core.database import Base, run_migrations
+
+
+@pytest.mark.asyncio
+async def test_billing_run_columns_and_legacy_archive_index_are_migrated(tmp_path):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'billing-run.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            await run_migrations(conn)
+
+            queue_columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_queue)"))).all()}
+            archive_columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()}
+            notification_columns = {
+                row[1] for row in (await conn.execute(text("PRAGMA table_info(notification_providers)"))).all()
+            }
+            archive_index_sql = await conn.scalar(
+                text("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uq_wallet_transactions_archive'")
+            )
+
+        assert "billing_run_id" in queue_columns
+        assert "billing_run_id" in archive_columns
+        assert "on_billing_charge_failed" in notification_columns
+        assert archive_index_sql is not None
+        assert "WHERE print_run_id IS NULL" in archive_index_sql
+    finally:
+        await engine.dispose()

+ 14 - 2
backend/tests/unit/test_finance_table_migration.py

@@ -18,7 +18,6 @@ EXPECTED_TABLES = {
     "wallet_transactions",
     "wallet_transactions",
     "budget_reservations",
     "budget_reservations",
     "cost_center_members",
     "cost_center_members",
-    "cost_center_invitations",
     "user_wallets",
     "user_wallets",
 }
 }
 
 
@@ -38,11 +37,22 @@ async def test_finance_tables_are_created_idempotently_on_sqlite():
                     "SELECT name FROM sqlite_master "
                     "SELECT name FROM sqlite_master "
                     "WHERE type = 'table' AND name IN "
                     "WHERE type = 'table' AND name IN "
                     "('cost_centers', 'wallet_transactions', 'budget_reservations', "
                     "('cost_centers', 'wallet_transactions', 'budget_reservations', "
-                    "'cost_center_members', 'cost_center_invitations', 'user_wallets')"
+                    "'cost_center_members', 'user_wallets')"
                 )
                 )
             )
             )
+            invitation_table = await conn.scalar(
+                text("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cost_center_invitations'")
+            )
+            wallet_columns = await conn.execute(text("PRAGMA table_info(user_wallets)"))
+            transaction_columns = await conn.execute(text("PRAGMA table_info(wallet_transactions)"))
 
 
         assert {row[0] for row in rows} == EXPECTED_TABLES
         assert {row[0] for row in rows} == EXPECTED_TABLES
+        assert invitation_table is None
+        assert {row[1]: row[2] for row in wallet_columns}["balance"] == "NUMERIC(14,2)"
+        transaction_types = {row[1]: row[2] for row in transaction_columns}
+        assert transaction_types["amount"] == "NUMERIC(14,2)"
+        assert transaction_types["balance_after"] == "NUMERIC(14,2)"
+        assert transaction_types["is_voided"] == "BOOLEAN"
     finally:
     finally:
         await engine.dispose()
         await engine.dispose()
 
 
@@ -114,6 +124,8 @@ async def test_postgres_finance_ddl_uses_postgres_types():
     assert all("DATETIME" not in sql for sql in create_statements)
     assert all("DATETIME" not in sql for sql in create_statements)
     assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
     assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
     assert "TIMESTAMP" in "\n".join(create_statements)
     assert "TIMESTAMP" in "\n".join(create_statements)
+    assert "NUMERIC(14,2)" in "\n".join(create_statements)
+    assert "is_voided BOOLEAN NOT NULL DEFAULT FALSE" in "\n".join(create_statements)
 
 
     created_tables = {
     created_tables = {
         sql.split("CREATE TABLE IF NOT EXISTS", 1)[1].split("(", 1)[0].strip() for sql in create_statements
         sql.split("CREATE TABLE IF NOT EXISTS", 1)[1].split("(", 1)[0].strip() for sql in create_statements

+ 26 - 4
backend/tests/unit/test_printer_kill_switch.py

@@ -8,6 +8,7 @@ from backend.app import main as main_module
 
 
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)
 def clear_kill_switch_state():
 def clear_kill_switch_state():
+    main_module._kill_switch_setting_cache = None
     main_module._unauthorized_print_kill_sent.clear()
     main_module._unauthorized_print_kill_sent.clear()
     main_module._kill_switch_notification_tasks.clear()
     main_module._kill_switch_notification_tasks.clear()
     main_module._expected_prints.clear()
     main_module._expected_prints.clear()
@@ -24,6 +25,7 @@ def clear_kill_switch_state():
     main_module._active_prints.clear()
     main_module._active_prints.clear()
     main_module._expected_print_registered_at.clear()
     main_module._expected_print_registered_at.clear()
     main_module._printer_reconciled_since_connect.clear()
     main_module._printer_reconciled_since_connect.clear()
+    main_module._kill_switch_setting_cache = None
 
 
 
 
 def test_gcode_3mf_status_filename_matches_registered_expected_print():
 def test_gcode_3mf_status_filename_matches_registered_expected_print():
@@ -51,8 +53,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
     async def kill_switch_enabled(_db):
     async def kill_switch_enabled(_db):
         return True
         return True
 
 
-    async def unauthorized(*_args):
-        return False
+    unauthorized = AsyncMock(return_value=False)
 
 
     monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
     monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
     monkeypatch.setattr(
     monkeypatch.setattr(
@@ -91,9 +92,11 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         gcode_file="foreign_job.gcode",
         gcode_file="foreign_job.gcode",
     )
     )
 
 
+    await main_module.on_printer_status_change(7, state)
     await main_module.on_printer_status_change(7, state)
     await main_module.on_printer_status_change(7, state)
 
 
     assert stop_calls == [7]
     assert stop_calls == [7]
+    unauthorized.assert_awaited_once()
     assert 7 in main_module._unauthorized_print_kill_sent
     assert 7 in main_module._unauthorized_print_kill_sent
     broadcast.assert_awaited_once_with(
     broadcast.assert_awaited_once_with(
         {
         {
@@ -139,8 +142,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
     async def fake_status(*args, **kwargs):
     async def fake_status(*args, **kwargs):
         return None
         return None
 
 
-    async def kill_switch_enabled(_db):
-        return True
+    kill_switch_enabled = AsyncMock(return_value=True)
 
 
     monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
     monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
     monkeypatch.setattr(
     monkeypatch.setattr(
@@ -179,6 +181,26 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
 
 
     assert stop_calls == []
     assert stop_calls == []
     assert 7 not in main_module._unauthorized_print_kill_sent
     assert 7 not in main_module._unauthorized_print_kill_sent
+    kill_switch_enabled.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_kill_switch_setting_is_cached(monkeypatch):
+    kill_switch_enabled = AsyncMock(return_value=True)
+
+    class FakeSessionContext:
+        async def __aenter__(self):
+            return SimpleNamespace()
+
+        async def __aexit__(self, *_args):
+            return False
+
+    monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
+    monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
+
+    assert await main_module._is_printer_kill_switch_enabled_cached() is True
+    assert await main_module._is_printer_kill_switch_enabled_cached() is True
+    kill_switch_enabled.assert_awaited_once()
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio

+ 27 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -150,6 +150,33 @@ describe('PrintModal', () => {
       expect(submitButton).toBeInTheDocument();
       expect(submitButton).toBeInTheDocument();
     });
     });
 
 
+    it('explains and blocks printing when billing has no printable cost center', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({
+          billing_enabled: true,
+          default_filament_cost: 25,
+          currency: 'USD',
+        })),
+        http.get('/api/v1/finance/cost-centers/mine', () => HttpResponse.json([])),
+      );
+
+      render(
+        <PrintModal
+          mode="create"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(await screen.findByRole('alert')).toHaveTextContent(
+        'No active cost center is available for printing. Ask an administrator to grant you print access.',
+      );
+      expect(screen.getByRole('button', { name: /^print$/i })).toBeDisabled();
+    });
+
     it('has cancel button', () => {
     it('has cancel button', () => {
       render(
       render(
         <PrintModal
         <PrintModal

+ 37 - 0
frontend/src/__tests__/components/PrintModalBilling.test.tsx

@@ -192,4 +192,41 @@ describe('PrintModal billing payloads', () => {
       expect(mockShowToast).toHaveBeenCalled();
       expect(mockShowToast).toHaveBeenCalled();
     });
     });
   });
   });
+
+  it('labels a cost center without a budget as unlimited', async () => {
+    server.use(
+      http.get('/api/v1/finance/cost-centers/mine', () => {
+        return HttpResponse.json([
+          {
+            id: 42,
+            name: 'Unlimited Lab',
+            is_private: false,
+            owner_user_id: null,
+            is_active: true,
+            total_balance: -25,
+            total_budget: null,
+            monthly_budget: null,
+            budget_mode: 'none',
+            budget_limit: null,
+            budget_used: null,
+            budget_available: null,
+            can_print: true,
+          },
+        ]);
+      }),
+    );
+
+    renderWithProviders(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Billing Print"
+        queueItem={mockQueueItem as never}
+        onClose={vi.fn()}
+        onSuccess={vi.fn()}
+      />,
+    );
+
+    expect(await screen.findByText('Unlimited – no budget limit is set.')).not.toBeNull();
+  });
 });
 });

+ 28 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -27,6 +27,10 @@ vi.mock('react-i18next', () => ({
         const { printer, filename } = options as { printer: string; filename: string };
         const { printer, filename } = options as { printer: string; filename: string };
         return `Billing kill switch stopped ${filename} on ${printer}`;
         return `Billing kill switch stopped ${filename} on ${printer}`;
       }
       }
+      if (key === 'printers.toast.billingChargeFailed' && options) {
+        const { printer, filename } = options as { printer: string; filename: string };
+        return `Billing failed for ${filename} on ${printer}. The budget reservation was retained; check the server logs.`;
+      }
       return key;
       return key;
     },
     },
     i18n: {},
     i18n: {},
@@ -516,6 +520,30 @@ describe('useWebSocket hook', () => {
       expect(toast.parentElement).toHaveClass('bg-red-500/10');
       expect(toast.parentElement).toHaveClass('bg-red-500/10');
     });
     });
 
 
+    it('shows an error toast when a completed print could not be charged', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+        ws.simulateMessage({
+          type: 'billing_charge_failed',
+          printer_id: 7,
+          printer_name: 'Printer B',
+          filename: 'paid-job.3mf',
+        });
+      });
+
+      const toast = screen.getByText(
+        'Billing failed for paid-job.3mf on Printer B. The budget reservation was retained; check the server logs.',
+      );
+      expect(toast.parentElement).toHaveClass('bg-red-500/10');
+    });
+
     it('handles spool_assignment_verified messages (success and failure) without error', async () => {
     it('handles spool_assignment_verified messages (success and failure) without error', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 

+ 3 - 0
frontend/src/api/client.ts

@@ -2679,6 +2679,7 @@ export interface NotificationProvider {
   on_print_stopped: boolean;
   on_print_stopped: boolean;
   on_print_progress: boolean;
   on_print_progress: boolean;
   on_print_missing_spool_assignment: boolean;
   on_print_missing_spool_assignment: boolean;
+  on_billing_charge_failed: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline: boolean;
   on_printer_offline: boolean;
   on_printer_error: boolean;
   on_printer_error: boolean;
@@ -2740,6 +2741,7 @@ export interface NotificationProviderCreate {
   on_print_stopped?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_progress?: boolean;
   on_print_missing_spool_assignment?: boolean;
   on_print_missing_spool_assignment?: boolean;
+  on_billing_charge_failed?: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
@@ -2794,6 +2796,7 @@ export interface NotificationProviderUpdate {
   on_print_stopped?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_progress?: boolean;
   on_print_missing_spool_assignment?: boolean;
   on_print_missing_spool_assignment?: boolean;
+  on_billing_charge_failed?: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;

+ 10 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -36,6 +36,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
   const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
   const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
   const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
+  const [onBillingChargeFailed, setOnBillingChargeFailed] = useState(provider?.on_billing_charge_failed ?? true);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
   const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
@@ -192,6 +193,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_print_failed: onPrintFailed,
       on_print_failed: onPrintFailed,
       on_print_stopped: onPrintStopped,
       on_print_stopped: onPrintStopped,
       on_print_progress: onPrintProgress,
       on_print_progress: onPrintProgress,
+      on_billing_charge_failed: onBillingChargeFailed,
       on_printer_offline: onPrinterOffline,
       on_printer_offline: onPrinterOffline,
       on_printer_error: onPrinterError,
       on_printer_error: onPrinterError,
       on_ai_failure_detection: onAiFailureDetection,
       on_ai_failure_detection: onAiFailureDetection,
@@ -605,6 +607,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.billingChargeFailedDescription')}</span>
+                  </div>
+                  <Toggle checked={onBillingChargeFailed} onChange={setOnBillingChargeFailed} />
+                </div>
                 <div className="flex items-center justify-between col-span-2">
                 <div className="flex items-center justify-between col-span-2">
                   <div>
                   <div>
                     <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
                     <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
@@ -692,6 +701,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
+              if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
               if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
               if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -132,6 +132,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_print_stopped && (
             {provider.on_print_stopped && (
               <span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400 text-xs rounded">{t('notifications.stopped')}</span>
               <span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400 text-xs rounded">{t('notifications.stopped')}</span>
             )}
             )}
+            {provider.on_billing_charge_failed && (
+              <span className="px-2 py-0.5 bg-red-100 dark:bg-red-600/20 text-red-700 dark:text-red-300 text-xs rounded">{t('notifications.billingChargeFailedLabel')}</span>
+            )}
             {provider.on_print_progress && (
             {provider.on_print_progress && (
               <span className="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 text-xs rounded">{t('notifications.progress')}</span>
               <span className="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 text-xs rounded">{t('notifications.progress')}</span>
             )}
             )}
@@ -354,6 +357,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                   />
                 </div>
                 </div>
 
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.billingChargeFailedDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_billing_charge_failed ?? true}
+                    onChange={(checked) => updateMutation.mutate({ on_billing_charge_failed: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <div>
                   <div>
                     <p className="text-sm text-white">{t('notifications.progressMilestones')}</p>
                     <p className="text-sm text-white">{t('notifications.progressMilestones')}</p>

+ 6 - 0
frontend/src/components/PrintModal/CostCenterSelect.tsx

@@ -15,6 +15,7 @@ export function CostCenterSelect({
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   if (costCenters.length === 0) return null;
   if (costCenters.length === 0) return null;
+  const selectedCostCenter = costCenters.find((center) => center.id === selectedCostCenterId);
 
 
   return (
   return (
     <div className="space-y-1">
     <div className="space-y-1">
@@ -33,6 +34,11 @@ export function CostCenterSelect({
           </option>
           </option>
         ))}
         ))}
       </select>
       </select>
+      {selectedCostCenter?.budget_mode === 'none' && (
+        <p className="text-xs text-bambu-gray">
+          {t('printModal.unlimitedNoBudget', 'Unlimited – no budget limit is set.')}
+        </p>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 23 - 1
frontend/src/components/PrintModal/index.tsx

@@ -315,7 +315,7 @@ export function PrintModal({
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
-  const { data: myCostCenters } = useQuery({
+  const { data: myCostCenters, isLoading: loadingCostCenters } = useQuery({
     queryKey: ['finance', 'cost-centers', 'mine'],
     queryKey: ['finance', 'cost-centers', 'mine'],
     queryFn: api.getMyCostCenters,
     queryFn: api.getMyCostCenters,
     enabled: !!user && billingEnabled,
     enabled: !!user && billingEnabled,
@@ -824,6 +824,11 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
     e?.preventDefault();
 
 
+    if (billingEnabled && selectedCostCenter == null) {
+      showToast(t('printModal.noPrintableCostCenters'), 'error');
+      return;
+    }
+
     if (
     if (
       !options?.skipFilamentCheck &&
       !options?.skipFilamentCheck &&
       !settings?.disable_filament_warnings &&
       !settings?.disable_filament_warnings &&
@@ -1277,6 +1282,11 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
   const canSubmit = useMemo(() => {
     if (isPending) return false;
     if (isPending) return false;
 
 
+    // Billing requires a server-authorized cost center. Wait for the query and
+    // keep submission disabled when the user has no printable center, rather
+    // than letting the API fail with an unexplained 400.
+    if (billingEnabled && (loadingCostCenters || selectedCostCenter == null)) return false;
+
     // Need valid printer/model selection
     // Need valid printer/model selection
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     // Both are about the single-model case. A cross-model job has no one target
     // Both are about the single-model case. A cross-model job has no one target
@@ -1313,6 +1323,9 @@ export function PrintModal({
     perPlateReqsFailed,
     perPlateReqsFailed,
     printerStatusLoading,
     printerStatusLoading,
     isCrossModel,
     isCrossModel,
+    billingEnabled,
+    loadingCostCenters,
+    selectedCostCenter,
   ]);
   ]);
 
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
@@ -1714,6 +1727,15 @@ export function PrintModal({
                 onChange={setSelectedCostCenterId}
                 onChange={setSelectedCostCenterId}
               />
               />
             )}
             )}
+            {billingEnabled && !loadingCostCenters && printableCostCenters.length === 0 && (
+              <div
+                role="alert"
+                className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-800 dark:text-yellow-300 flex items-start gap-2"
+              >
+                <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
+                {t('printModal.noPrintableCostCenters')}
+              </div>
+            )}
 
 
             {/* Quantity — create multiple copies (batch). Hidden for multi-printer
             {/* Quantity — create multiple copies (batch). Hidden for multi-printer
                 selection, and for multi-plate files where the per-plate steppers
                 selection, and for multi-plate files where the per-plate steppers

+ 7 - 0
frontend/src/hooks/useWebSocket.ts

@@ -346,6 +346,13 @@ export function useWebSocket() {
         break;
         break;
       }
       }
 
 
+      case 'billing_charge_failed': {
+        const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
+        const filename = message.filename || t('common.unknown');
+        showToast(t('printers.toast.billingChargeFailed', { printer, filename }), 'error');
+        break;
+      }
+
       case 'archive_created':
       case 'archive_created':
         debouncedInvalidate('archives');
         debouncedInvalidate('archives');
         debouncedInvalidate('archiveStats');
         debouncedInvalidate('archiveStats');

+ 9 - 2
frontend/src/i18n/locales/de.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Budgetgrenzen prüfen und Kosten im Blick behalten',
     costCentersHint: 'Budgetgrenzen prüfen und Kosten im Blick behalten',
     noCostCenters: 'Keine Kostenstellen gefunden.',
     noCostCenters: 'Keine Kostenstellen gefunden.',
     owner: 'Besitzer',
     owner: 'Besitzer',
-    balance: 'Saldo',
+    balance: 'Kontostand',
+    unlimited: 'Unbegrenzt',
+    budgetPolicyHint: 'Kontostände dienen der Kostenübersicht. Drucke werden ausschließlich durch das Budget der ausgewählten Kostenstelle begrenzt; ohne Budget kann unbegrenzt gedruckt werden.',
     budget: 'Budget',
     budget: 'Budget',
     shared: 'Geteilt',
     shared: 'Geteilt',
     cannotEditPrivateCostCenter: 'Private Kostenstellen können hier nicht bearbeitet werden',
     cannotEditPrivateCostCenter: 'Private Kostenstellen können hier nicht bearbeitet werden',
@@ -182,7 +184,7 @@ export default {
     noTransactions: 'Keine Transaktionen verfügbar.',
     noTransactions: 'Keine Transaktionen verfügbar.',
     noTransactionsForFilter: 'Keine Transaktionen entsprechen den ausgewählten Filtern.',
     noTransactionsForFilter: 'Keine Transaktionen entsprechen den ausgewählten Filtern.',
     costCenter: 'Kostenstelle',
     costCenter: 'Kostenstelle',
-    balanceAfter: 'Saldo nachher',
+    balanceAfter: 'Kontostand danach',
     userWithId: 'Benutzer #{{id}}',
     userWithId: 'Benutzer #{{id}}',
     partial: 'Teilweise',
     partial: 'Teilweise',
     editTransaction: 'Transaktion bearbeiten',
     editTransaction: 'Transaktion bearbeiten',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Drucker gelöscht',
       printerDeleted: 'Drucker gelöscht',
       missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
       missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
       killSwitchTriggered: 'Der Billing-Kill-Switch hat einen nicht autorisierten Druck auf {{printer}} gestoppt: {{filename}}',
       killSwitchTriggered: 'Der Billing-Kill-Switch hat einen nicht autorisierten Druck auf {{printer}} gestoppt: {{filename}}',
+      billingChargeFailed: 'Die Abrechnung für {{filename}} auf {{printer}} ist fehlgeschlagen. Die Budgetreservierung bleibt bestehen; prüfe die Serverprotokolle.',
       assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
       assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
       assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
       assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
@@ -4893,6 +4896,8 @@ export default {
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     gcodeInjection: 'Auto-Print G-code einfügen',
     gcodeInjection: 'Auto-Print G-code einfügen',
     insufficientBudget: 'Budget nicht ausreichend',
     insufficientBudget: 'Budget nicht ausreichend',
+    unlimitedNoBudget: 'Unbegrenzt – es ist kein Budgetlimit festgelegt.',
+    noPrintableCostCenters: 'Es ist keine aktive Kostenstelle zum Drucken verfügbar. Bitte einen Administrator, dir Druckzugriff zu gewähren.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5813,6 +5818,8 @@ export default {
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
     missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
+    billingChargeFailedLabel: 'Abrechnungsfehler',
+    billingChargeFailedDescription: 'Benachrichtigen, wenn Druckkosten nicht verbucht werden konnten',
     missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
     missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
     printFailed: 'Druck fehlgeschlagen',
     printFailed: 'Druck fehlgeschlagen',
     printStopped: 'Druck gestoppt',
     printStopped: 'Druck gestoppt',

+ 8 - 1
frontend/src/i18n/locales/en.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Review budget limits and keep costs under control',
     costCentersHint: 'Review budget limits and keep costs under control',
     noCostCenters: 'No cost centers found.',
     noCostCenters: 'No cost centers found.',
     owner: 'Owner',
     owner: 'Owner',
-    balance: 'Balance',
+    balance: 'Account balance',
+    unlimited: 'Unlimited',
+    budgetPolicyHint: 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.',
     budget: 'Budget',
     budget: 'Budget',
     shared: 'Shared',
     shared: 'Shared',
     cannotEditPrivateCostCenter: 'Private cost centers cannot be edited here',
     cannotEditPrivateCostCenter: 'Private cost centers cannot be edited here',
@@ -451,6 +453,7 @@ export default {
       printerDeleted: 'Printer deleted',
       printerDeleted: 'Printer deleted',
       missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
       missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
       killSwitchTriggered: 'The billing kill switch stopped an unauthorized print on {{printer}}: {{filename}}',
       killSwitchTriggered: 'The billing kill switch stopped an unauthorized print on {{printer}}: {{filename}}',
+      billingChargeFailed: 'Billing failed for {{filename}} on {{printer}}. The budget reservation was retained; check the server logs.',
       assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
       assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
       assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
       assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
@@ -4936,6 +4939,8 @@ export default {
     staggerToPrinters: 'Stagger to {{count}} printers',
     staggerToPrinters: 'Stagger to {{count}} printers',
     gcodeInjection: 'Inject auto-print G-code',
     gcodeInjection: 'Inject auto-print G-code',
     insufficientBudget: 'Insufficient Budget',
     insufficientBudget: 'Insufficient Budget',
+    unlimitedNoBudget: 'Unlimited – no budget limit is set.',
+    noPrintableCostCenters: 'No active cost center is available for printing. Ask an administrator to grant you print access.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5857,6 +5862,8 @@ export default {
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     missingSpoolAssignmentLabel: 'Missing Spool Assignment',
     missingSpoolAssignmentLabel: 'Missing Spool Assignment',
+    billingChargeFailedLabel: 'Billing Charge Failed',
+    billingChargeFailedDescription: 'Notify when print costs could not be recorded',
     missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
     missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
     printFailed: 'Print Failed',
     printFailed: 'Print Failed',
     printStopped: 'Print Stopped',
     printStopped: 'Print Stopped',

+ 8 - 1
frontend/src/i18n/locales/es.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Revisa los límites presupuestarios y mantén los costes bajo control',
     costCentersHint: 'Revisa los límites presupuestarios y mantén los costes bajo control',
     noCostCenters: 'No se encontraron centros de costes.',
     noCostCenters: 'No se encontraron centros de costes.',
     owner: 'Propietario',
     owner: 'Propietario',
-    balance: 'Saldo',
+    balance: 'Saldo de cuenta',
+    unlimited: 'Ilimitado',
+    budgetPolicyHint: 'Los saldos de cuenta registran los costes. La impresión solo está limitada por el presupuesto del centro de costes seleccionado; sin presupuesto, es ilimitada.',
     budget: 'Presupuesto',
     budget: 'Presupuesto',
     shared: 'Compartido',
     shared: 'Compartido',
     cannotEditPrivateCostCenter: 'Los centros de costes privados no se pueden editar aquí',
     cannotEditPrivateCostCenter: 'Los centros de costes privados no se pueden editar aquí',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Impresora eliminada',
       printerDeleted: 'Impresora eliminada',
       missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
       missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
       killSwitchTriggered: 'El interruptor de seguridad de facturación detuvo una impresión no autorizada en {{printer}}: {{filename}}',
       killSwitchTriggered: 'El interruptor de seguridad de facturación detuvo una impresión no autorizada en {{printer}}: {{filename}}',
+      billingChargeFailed: 'La facturación de {{filename}} en {{printer}} ha fallado. Se conservó la reserva de presupuesto; revisa los registros del servidor.',
       assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
       assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
       assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
       assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
       assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
@@ -4900,6 +4903,8 @@ export default {
     staggerToPrinters: 'Escalonar en {{count}} impresoras',
     staggerToPrinters: 'Escalonar en {{count}} impresoras',
     gcodeInjection: 'Inyectar G-code de impresión automática',
     gcodeInjection: 'Inyectar G-code de impresión automática',
     insufficientBudget: 'Presupuesto insuficiente',
     insufficientBudget: 'Presupuesto insuficiente',
+    unlimitedNoBudget: 'Ilimitado: no se ha establecido ningún límite de presupuesto.',
+    noPrintableCostCenters: 'No hay ningún centro de costes activo disponible para imprimir. Pide a un administrador que te conceda acceso de impresión.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5821,6 +5826,8 @@ export default {
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
     missingSpoolAssignmentLabel: 'Falta la asignación de bobina',
     missingSpoolAssignmentLabel: 'Falta la asignación de bobina',
+    billingChargeFailedLabel: 'Error de facturación',
+    billingChargeFailedDescription: 'Notificar cuando no se puedan registrar los costes de impresión',
     missingSpoolAssignmentDescription: 'Notificar cuando la impresión comienza y las bandejas necesarias no tienen ninguna bobina asignada',
     missingSpoolAssignmentDescription: 'Notificar cuando la impresión comienza y las bandejas necesarias no tienen ninguna bobina asignada',
     printFailed: 'Impresión fallida',
     printFailed: 'Impresión fallida',
     printStopped: 'Impresión detenida',
     printStopped: 'Impresión detenida',

+ 8 - 1
frontend/src/i18n/locales/fr.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Vérifier les limites budgétaires et garder les coûts sous contrôle',
     costCentersHint: 'Vérifier les limites budgétaires et garder les coûts sous contrôle',
     noCostCenters: 'Aucun centre de coûts trouvé.',
     noCostCenters: 'Aucun centre de coûts trouvé.',
     owner: 'Propriétaire',
     owner: 'Propriétaire',
-    balance: 'Solde',
+    balance: 'Solde du compte',
+    unlimited: 'Illimité',
+    budgetPolicyHint: "Les soldes de compte servent au suivi des coûts. L’impression est limitée uniquement par le budget du centre de coûts sélectionné ; sans budget, elle est illimitée.",
     budget: 'Budget',
     budget: 'Budget',
     shared: 'Partagé',
     shared: 'Partagé',
     cannotEditPrivateCostCenter: 'Les centres de coûts privés ne peuvent pas être modifiés ici',
     cannotEditPrivateCostCenter: 'Les centres de coûts privés ne peuvent pas être modifiés ici',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Imprimante supprimée',
       printerDeleted: 'Imprimante supprimée',
       missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
       missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
       killSwitchTriggered: 'Le coupe-circuit de facturation a arrêté une impression non autorisée sur {{printer}} : {{filename}}',
       killSwitchTriggered: 'Le coupe-circuit de facturation a arrêté une impression non autorisée sur {{printer}} : {{filename}}',
+      billingChargeFailed: 'La facturation de {{filename}} sur {{printer}} a échoué. La réservation budgétaire a été conservée ; consultez les journaux du serveur.',
       assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
       assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
       assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
       assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
       assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
@@ -4882,6 +4885,8 @@ export default {
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
     gcodeInjection: 'Injecter le G-code auto-impression',
     gcodeInjection: 'Injecter le G-code auto-impression',
     insufficientBudget: 'Budget insuffisant',
     insufficientBudget: 'Budget insuffisant',
+    unlimitedNoBudget: 'Illimité – aucune limite de budget n’est définie.',
+    noPrintableCostCenters: 'Aucun centre de coûts actif n’est disponible pour l’impression. Demandez à un administrateur de vous accorder l’accès à l’impression.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5803,6 +5808,8 @@ export default {
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
     missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
+    billingChargeFailedLabel: 'Échec de facturation',
+    billingChargeFailedDescription: "Notifier lorsque les coûts d'impression ne peuvent pas être enregistrés",
     missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
     missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
     printFailed: 'Impression échouée',
     printFailed: 'Impression échouée',
     printStopped: 'Impression arrêtée',
     printStopped: 'Impression arrêtée',

+ 8 - 1
frontend/src/i18n/locales/it.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Controlla i limiti di budget e mantieni i costi sotto controllo',
     costCentersHint: 'Controlla i limiti di budget e mantieni i costi sotto controllo',
     noCostCenters: 'Nessun centro di costo trovato.',
     noCostCenters: 'Nessun centro di costo trovato.',
     owner: 'Proprietario',
     owner: 'Proprietario',
-    balance: 'Saldo',
+    balance: 'Saldo conto',
+    unlimited: 'Illimitato',
+    budgetPolicyHint: 'I saldi dei conti registrano i costi. La stampa è limitata solo dal budget del centro di costo selezionato; senza budget è illimitata.',
     budget: 'Budget',
     budget: 'Budget',
     shared: 'Condiviso',
     shared: 'Condiviso',
     cannotEditPrivateCostCenter: 'I centri di costo privati non possono essere modificati qui',
     cannotEditPrivateCostCenter: 'I centri di costo privati non possono essere modificati qui',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Stampante eliminata',
       printerDeleted: 'Stampante eliminata',
       missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
       missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
       killSwitchTriggered: 'L’interruttore di sicurezza della fatturazione ha arrestato una stampa non autorizzata su {{printer}}: {{filename}}',
       killSwitchTriggered: 'L’interruttore di sicurezza della fatturazione ha arrestato una stampa non autorizzata su {{printer}}: {{filename}}',
+      billingChargeFailed: 'La fatturazione di {{filename}} su {{printer}} non è riuscita. La prenotazione del budget è stata mantenuta; controlla i log del server.',
       assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
       assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
       assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
       assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
@@ -4881,6 +4884,8 @@ export default {
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     gcodeInjection: 'Inietta G-code auto-stampa',
     gcodeInjection: 'Inietta G-code auto-stampa',
     insufficientBudget: 'Budget insufficiente',
     insufficientBudget: 'Budget insufficiente',
+    unlimitedNoBudget: 'Illimitato – non è impostato alcun limite di budget.',
+    noPrintableCostCenters: 'Non è disponibile alcun centro di costo attivo per la stampa. Chiedi a un amministratore di concederti l’accesso alla stampa.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5802,6 +5807,8 @@ export default {
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
     missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
+    billingChargeFailedLabel: 'Errore di addebito',
+    billingChargeFailedDescription: 'Notifica quando non è possibile registrare i costi di stampa',
     missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
     missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
     printFailed: 'Stampa fallita',
     printFailed: 'Stampa fallita',
     printStopped: 'Stampa interrotta',
     printStopped: 'Stampa interrotta',

+ 8 - 1
frontend/src/i18n/locales/ja.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: '予算限度額を確認し、コストを管理下に置きます',
     costCentersHint: '予算限度額を確認し、コストを管理下に置きます',
     noCostCenters: 'コストセンターが見つかりません。',
     noCostCenters: 'コストセンターが見つかりません。',
     owner: '所有者',
     owner: '所有者',
-    balance: '残高',
+    balance: '口座残高',
+    unlimited: '無制限',
+    budgetPolicyHint: '口座残高はコストの記録に使用されます。印刷は選択したコストセンターの予算によってのみ制限され、予算がなければ無制限です。',
     budget: '予算',
     budget: '予算',
     shared: '共有',
     shared: '共有',
     cannotEditPrivateCostCenter: 'プライベートコストセンターはここで編集できません',
     cannotEditPrivateCostCenter: 'プライベートコストセンターはここで編集できません',
@@ -447,6 +449,7 @@ export default {
       printerDeleted: 'プリンターを削除しました',
       printerDeleted: 'プリンターを削除しました',
       missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
       missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
       killSwitchTriggered: '課金キルスイッチが{{printer}}で未承認の印刷を停止しました:{{filename}}',
       killSwitchTriggered: '課金キルスイッチが{{printer}}で未承認の印刷を停止しました:{{filename}}',
+      billingChargeFailed: '{{printer}} の {{filename}} を課金できませんでした。予算予約は保持されています。サーバーログを確認してください。',
       assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
       assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
       assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
       assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
       assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
@@ -4893,6 +4896,8 @@ export default {
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     gcodeInjection: '自動印刷G-codeを挿入',
     gcodeInjection: '自動印刷G-codeを挿入',
     insufficientBudget: '予算が不足しています',
     insufficientBudget: '予算が不足しています',
+    unlimitedNoBudget: '無制限 – 予算上限は設定されていません。',
+    noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。',
   },
   },
 
 
   // Backup
   // Backup
@@ -5814,6 +5819,8 @@ export default {
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     missingSpoolAssignmentLabel: 'スプール割り当て不足',
     missingSpoolAssignmentLabel: 'スプール割り当て不足',
+    billingChargeFailedLabel: '請求処理エラー',
+    billingChargeFailedDescription: '印刷コストを記録できなかった場合に通知します',
     missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     printFailed: '印刷失敗',
     printFailed: '印刷失敗',
     printStopped: '印刷停止',
     printStopped: '印刷停止',

+ 9 - 2
frontend/src/i18n/locales/ko.ts

@@ -164,7 +164,9 @@ export default {
     costCentersHint: '예산 한도를 검토하고 비용을 관리하세요',
     costCentersHint: '예산 한도를 검토하고 비용을 관리하세요',
     noCostCenters: '비용 센터를 찾을 수 없습니다.',
     noCostCenters: '비용 센터를 찾을 수 없습니다.',
     owner: '소유자',
     owner: '소유자',
-    balance: '잔액',
+    balance: '계정 잔액',
+    unlimited: '무제한',
+    budgetPolicyHint: '계정 잔액은 비용을 기록합니다. 인쇄는 선택한 비용 센터의 예산으로만 제한되며, 예산이 없으면 무제한입니다.',
     budget: '예산',
     budget: '예산',
     shared: '공유',
     shared: '공유',
     cannotEditPrivateCostCenter: '비공개 비용 센터는 여기에서 편집할 수 없습니다',
     cannotEditPrivateCostCenter: '비공개 비용 센터는 여기에서 편집할 수 없습니다',
@@ -422,6 +424,7 @@ export default {
       printerDeleted: '프린터가 삭제되었습니다',
       printerDeleted: '프린터가 삭제되었습니다',
       missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
       missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
       killSwitchTriggered: '결제 킬 스위치가 {{printer}}에서 승인되지 않은 인쇄를 중지했습니다: {{filename}}',
       killSwitchTriggered: '결제 킬 스위치가 {{printer}}에서 승인되지 않은 인쇄를 중지했습니다: {{filename}}',
+      billingChargeFailed: '{{printer}}의 {{filename}} 결제에 실패했습니다. 예산 예약은 유지되었습니다. 서버 로그를 확인하세요.',
       assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
       assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
       assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
       assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
       assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
@@ -4662,7 +4665,9 @@ export default {
     staggerTotal: '합계: {{minutes}}분',
     staggerTotal: '합계: {{minutes}}분',
     staggerToPrinters: '{{count}}대 프린터에 분산',
     staggerToPrinters: '{{count}}대 프린터에 분산',
     gcodeInjection: '자동 인쇄 G-code 삽입',
     gcodeInjection: '자동 인쇄 G-code 삽입',
-    insufficientBudget: '예산 부족'
+    insufficientBudget: '예산 부족',
+    unlimitedNoBudget: '무제한 – 예산 한도가 설정되지 않았습니다.',
+    noPrintableCostCenters: '인쇄에 사용할 수 있는 활성 비용 센터가 없습니다. 관리자에게 인쇄 권한을 요청하세요.',
   },
   },
   backup: {
   backup: {
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
@@ -5532,6 +5537,8 @@ export default {
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
     missingSpoolAssignmentLabel: '스풀 할당 누락',
     missingSpoolAssignmentLabel: '스풀 할당 누락',
+    billingChargeFailedLabel: '결제 처리 실패',
+    billingChargeFailedDescription: '인쇄 비용을 기록하지 못한 경우 알림',
     missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림',
     missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림',
     printFailed: '인쇄 실패',
     printFailed: '인쇄 실패',
     printStopped: '인쇄 중지됨',
     printStopped: '인쇄 중지됨',

+ 8 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Revise os limites de orçamento e mantenha os custos sob controle',
     costCentersHint: 'Revise os limites de orçamento e mantenha os custos sob controle',
     noCostCenters: 'Nenhum centro de custo encontrado.',
     noCostCenters: 'Nenhum centro de custo encontrado.',
     owner: 'Proprietário',
     owner: 'Proprietário',
-    balance: 'Saldo',
+    balance: 'Saldo da conta',
+    unlimited: 'Ilimitado',
+    budgetPolicyHint: 'Os saldos das contas registram os custos. A impressão é limitada apenas pelo orçamento do centro de custo selecionado; sem orçamento, é ilimitada.',
     budget: 'Orçamento',
     budget: 'Orçamento',
     shared: 'Compartilhado',
     shared: 'Compartilhado',
     cannotEditPrivateCostCenter: 'Centros de custo privados não podem ser editados aqui',
     cannotEditPrivateCostCenter: 'Centros de custo privados não podem ser editados aqui',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Impressora excluída',
       printerDeleted: 'Impressora excluída',
       missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
       missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
       killSwitchTriggered: 'O bloqueio de segurança de cobrança interrompeu uma impressão não autorizada em {{printer}}: {{filename}}',
       killSwitchTriggered: 'O bloqueio de segurança de cobrança interrompeu uma impressão não autorizada em {{printer}}: {{filename}}',
+      billingChargeFailed: 'A cobrança de {{filename}} em {{printer}} falhou. A reserva do orçamento foi mantida; verifique os logs do servidor.',
       assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
       assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
       assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
       assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
       assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
@@ -4881,6 +4884,8 @@ export default {
     staggerToPrinters: 'Escalonar para {{count}} impressoras',
     staggerToPrinters: 'Escalonar para {{count}} impressoras',
     gcodeInjection: 'Injetar G-code de auto-impressão',
     gcodeInjection: 'Injetar G-code de auto-impressão',
     insufficientBudget: 'Orçamento insuficiente',
     insufficientBudget: 'Orçamento insuficiente',
+    unlimitedNoBudget: 'Ilimitado – nenhum limite de orçamento foi definido.',
+    noPrintableCostCenters: 'Não há nenhum centro de custo ativo disponível para impressão. Peça a um administrador para conceder acesso de impressão.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5802,6 +5807,8 @@ export default {
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
     missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
     missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
+    billingChargeFailedLabel: 'Falha na cobrança',
+    billingChargeFailedDescription: 'Notificar quando os custos de impressão não puderem ser registrados',
     missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
     missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
     printFailed: 'Impressão Falhou',
     printFailed: 'Impressão Falhou',
     printStopped: 'Impressão Parada',
     printStopped: 'Impressão Parada',

+ 8 - 1
frontend/src/i18n/locales/ru.ts

@@ -164,7 +164,9 @@ export default {
     costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
     costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
     noCostCenters: "Центры затрат не найдены.",
     noCostCenters: "Центры затрат не найдены.",
     owner: "Владелец",
     owner: "Владелец",
-    balance: "Баланс",
+    balance: "Баланс счёта",
+    unlimited: "Без ограничений",
+    budgetPolicyHint: "Баланс счёта используется для учёта затрат. Печать ограничивается только бюджетом выбранного центра затрат; без бюджета ограничений нет.",
     budget: "Бюджет",
     budget: "Бюджет",
     shared: "Общий",
     shared: "Общий",
     cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
     cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
@@ -427,6 +429,7 @@ export default {
       printerDeleted: "Принтер удалён",
       printerDeleted: "Принтер удалён",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
       killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
       killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не удалось начислить стоимость {{filename}} на {{printer}}. Резерв бюджета сохранён; проверьте журналы сервера.',
       assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
       assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
       assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
       assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
       assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
       assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
@@ -4652,6 +4655,8 @@ export default {
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     gcodeInjection: "Добавить G-code автозапуска",
     gcodeInjection: "Добавить G-code автозапуска",
     insufficientBudget: "Недостаточно бюджета",
     insufficientBudget: "Недостаточно бюджета",
+    unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
+    noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
   },
   },
   backup: {
   backup: {
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5519,6 +5524,8 @@ export default {
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
     missingSpoolAssignmentLabel: "Катушка не назначена",
     missingSpoolAssignmentLabel: "Катушка не назначена",
+    billingChargeFailedLabel: 'Ошибка списания',
+    billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати',
     missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
     missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
     printFailed: "Ошибка печати",
     printFailed: "Ошибка печати",
     printStopped: "Печать остановлена",
     printStopped: "Печать остановлена",

+ 8 - 1
frontend/src/i18n/locales/tr.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: 'Bütçe sınırlarını gözden geçirin ve maliyetleri kontrol altında tutun',
     costCentersHint: 'Bütçe sınırlarını gözden geçirin ve maliyetleri kontrol altında tutun',
     noCostCenters: 'Masraf merkezi bulunamadı.',
     noCostCenters: 'Masraf merkezi bulunamadı.',
     owner: 'Sahip',
     owner: 'Sahip',
-    balance: 'Bakiye',
+    balance: 'Hesap bakiyesi',
+    unlimited: 'Sınırsız',
+    budgetPolicyHint: 'Hesap bakiyeleri maliyetleri kaydeder. Baskı yalnızca seçilen masraf merkezinin bütçesiyle sınırlıdır; bütçe yoksa sınırsızdır.',
     budget: 'Bütçe',
     budget: 'Bütçe',
     shared: 'Paylaşılan',
     shared: 'Paylaşılan',
     cannotEditPrivateCostCenter: 'Özel masraf merkezleri burada düzenlenemez',
     cannotEditPrivateCostCenter: 'Özel masraf merkezleri burada düzenlenemez',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Yazıcı silindi',
       printerDeleted: 'Yazıcı silindi',
       missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
       missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
       killSwitchTriggered: 'Faturalandırma durdurma anahtarı {{printer}} üzerindeki yetkisiz baskıyı durdurdu: {{filename}}',
       killSwitchTriggered: 'Faturalandırma durdurma anahtarı {{printer}} üzerindeki yetkisiz baskıyı durdurdu: {{filename}}',
+      billingChargeFailed: '{{printer}} üzerindeki {{filename}} için faturalandırma başarısız oldu. Bütçe rezervasyonu korundu; sunucu günlüklerini kontrol edin.',
       assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
       assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
       assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
       assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
       assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
@@ -4870,6 +4873,8 @@ export default {
     staggerToPrinters: '{{count}} yazıcıya kademelendir',
     staggerToPrinters: '{{count}} yazıcıya kademelendir',
     gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
     gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
     insufficientBudget: 'Yetersiz bütçe',
     insufficientBudget: 'Yetersiz bütçe',
+    unlimitedNoBudget: 'Sınırsız – bütçe limiti belirlenmemiş.',
+    noPrintableCostCenters: 'Yazdırma için kullanılabilir etkin bir masraf merkezi yok. Bir yöneticiden yazdırma erişimi vermesini isteyin.',
   },
   },
 
 
   // Yedekleme
   // Yedekleme
@@ -5769,6 +5774,8 @@ export default {
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
     missingSpoolAssignmentLabel: 'Eksik Makara Ataması',
     missingSpoolAssignmentLabel: 'Eksik Makara Ataması',
+    billingChargeFailedLabel: 'Ücretlendirme hatası',
+    billingChargeFailedDescription: 'Baskı maliyetleri kaydedilemediğinde bildirim gönder',
     missingSpoolAssignmentDescription: 'Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir',
     missingSpoolAssignmentDescription: 'Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir',
     printFailed: 'Baskı Başarısız',
     printFailed: 'Baskı Başarısız',
     printStopped: 'Baskı Durduruldu',
     printStopped: 'Baskı Durduruldu',

+ 8 - 1
frontend/src/i18n/locales/uk.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: "Переглядайте бюджетні ліміти та контролюйте витрати",
     costCentersHint: "Переглядайте бюджетні ліміти та контролюйте витрати",
     noCostCenters: "Центрів витрат не знайдено.",
     noCostCenters: "Центрів витрат не знайдено.",
     owner: "Власник",
     owner: "Власник",
-    balance: "Баланс",
+    balance: "Баланс рахунку",
+    unlimited: "Без обмежень",
+    budgetPolicyHint: "Баланс рахунку використовується для обліку витрат. Друк обмежується лише бюджетом вибраного центру витрат; без бюджету обмежень немає.",
     budget: "Бюджет",
     budget: "Бюджет",
     shared: "Спільний",
     shared: "Спільний",
     cannotEditPrivateCostCenter: "Особисті центри витрат не можна редагувати тут",
     cannotEditPrivateCostCenter: "Особисті центри витрат не можна редагувати тут",
@@ -451,6 +453,7 @@ export default {
       printerDeleted: "Принтер видалено",
       printerDeleted: "Принтер видалено",
       missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
       missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
       killSwitchTriggered: 'Аварійний вимикач білінгу зупинив несанкціонований друк на {{printer}}: {{filename}}',
       killSwitchTriggered: 'Аварійний вимикач білінгу зупинив несанкціонований друк на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не вдалося нарахувати вартість {{filename}} на {{printer}}. Резерв бюджету збережено; перевірте журнали сервера.',
       assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
       assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
       assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
       assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
       assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
       assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
@@ -4935,6 +4938,8 @@ export default {
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     gcodeInjection: "Додати G-код автоматичного друку",
     gcodeInjection: "Додати G-код автоматичного друку",
     insufficientBudget: "Недостатньо бюджету",
     insufficientBudget: "Недостатньо бюджету",
+    unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.",
+    noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.",
   },
   },
 
 
   // Backup
   // Backup
@@ -5856,6 +5861,8 @@ export default {
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
     missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
     missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
+    billingChargeFailedLabel: 'Помилка списання',
+    billingChargeFailedDescription: 'Сповіщати, якщо не вдалося облікувати вартість друку',
     missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
     missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
     printFailed: "Помилка друку",
     printFailed: "Помилка друку",
     printStopped: "Друк зупинено",
     printStopped: "Друк зупинено",

+ 8 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: '审查预算限制并将成本控制在可控范围内',
     costCentersHint: '审查预算限制并将成本控制在可控范围内',
     noCostCenters: '未找到成本中心。',
     noCostCenters: '未找到成本中心。',
     owner: '所有者',
     owner: '所有者',
-    balance: '余额',
+    balance: '账户余额',
+    unlimited: '无限制',
+    budgetPolicyHint: '账户余额用于记录成本。打印仅受所选成本中心预算限制;未设置预算时不受限制。',
     budget: '预算',
     budget: '预算',
     shared: '共享',
     shared: '共享',
     cannotEditPrivateCostCenter: '私人成本中心无法在此处编辑',
     cannotEditPrivateCostCenter: '私人成本中心无法在此处编辑',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: '打印机已删除',
       printerDeleted: '打印机已删除',
       missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
       missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
       killSwitchTriggered: '计费终止开关已停止 {{printer}} 上的未授权打印:{{filename}}',
       killSwitchTriggered: '计费终止开关已停止 {{printer}} 上的未授权打印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 计费失败。预算预留已保留;请检查服务器日志。',
       assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
       assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
       assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
       assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
@@ -4881,6 +4884,8 @@ export default {
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     gcodeInjection: '注入自动打印G-code',
     gcodeInjection: '注入自动打印G-code',
     insufficientBudget: '预算不足',
     insufficientBudget: '预算不足',
+    unlimitedNoBudget: '无限制 – 未设置预算上限。',
+    noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。',
   },
   },
 
 
   // Backup
   // Backup
@@ -5802,6 +5807,8 @@ export default {
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '计费失败',
+    billingChargeFailedDescription: '无法记录打印费用时通知',
     missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     printFailed: '打印失败',
     printFailed: '打印失败',
     printStopped: '打印已停止',
     printStopped: '打印已停止',

+ 8 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -168,7 +168,9 @@ export default {
     costCentersHint: '檢視預算限制並將成本保持在控制範圍內',
     costCentersHint: '檢視預算限制並將成本保持在控制範圍內',
     noCostCenters: '未找到成本中心。',
     noCostCenters: '未找到成本中心。',
     owner: '擁有者',
     owner: '擁有者',
-    balance: '餘額',
+    balance: '帳戶餘額',
+    unlimited: '無限制',
+    budgetPolicyHint: '帳戶餘額用於記錄成本。列印僅受所選成本中心預算限制;未設定預算時不受限制。',
     budget: '預算',
     budget: '預算',
     shared: '共用',
     shared: '共用',
     cannotEditPrivateCostCenter: '無法在此處編輯私人成本中心',
     cannotEditPrivateCostCenter: '無法在此處編輯私人成本中心',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: '印表機已刪除',
       printerDeleted: '印表機已刪除',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
       killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
       killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 計費失敗。預算保留已保留;請檢查伺服器記錄。',
       assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
       assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
       assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
       assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
@@ -4881,6 +4884,8 @@ export default {
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     gcodeInjection: '注入自動列印G-code',
     gcodeInjection: '注入自動列印G-code',
     insufficientBudget: '預算不足',
     insufficientBudget: '預算不足',
+    unlimitedNoBudget: '無限制 – 未設定預算上限。',
+    noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
   },
   },
 
 
   // Backup
   // Backup
@@ -5802,6 +5807,8 @@ export default {
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '計費失敗',
+    billingChargeFailedDescription: '無法記錄列印費用時通知',
     missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
     missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
     printFailed: '列印失敗',
     printFailed: '列印失敗',
     printStopped: '列印已停止',
     printStopped: '列印已停止',

+ 51 - 20
frontend/src/pages/FinancePage.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api, type ManualPrintRequest, type TransactionEditRequest, type WalletTransaction } from '../api/client';
 import { api, type ManualPrintRequest, type TransactionEditRequest, type WalletTransaction } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
+import { ConfirmModal } from '../components/ConfirmModal';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
@@ -159,6 +160,8 @@ export function FinancePage() {
   const [manualPrintAmount, setManualPrintAmount] = useState('');
   const [manualPrintAmount, setManualPrintAmount] = useState('');
   const [manualPrintDescription, setManualPrintDescription] = useState('');
   const [manualPrintDescription, setManualPrintDescription] = useState('');
   const [manualPrintDate, setManualPrintDate] = useState(formatLocalDateTime(new Date()));
   const [manualPrintDate, setManualPrintDate] = useState(formatLocalDateTime(new Date()));
+  const [pendingDeleteCenter, setPendingDeleteCenter] = useState<{ id: number; name: string } | null>(null);
+  const [pendingDeleteTransactionId, setPendingDeleteTransactionId] = useState<number | null>(null);
 
 
   const hasAdminFinanceControls =
   const hasAdminFinanceControls =
     canReadAllFinance ||
     canReadAllFinance ||
@@ -440,31 +443,27 @@ export function FinancePage() {
     setShowEditCenterModal(true);
     setShowEditCenterModal(true);
   };
   };
 
 
-  const handleDeleteCenter = async (centerId: number, centerName: string) => {
-    const confirmed = window.confirm(
-      t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', { name: centerName })
-    );
-    if (!confirmed) return;
-
+  const confirmDeleteCenter = async () => {
+    if (!pendingDeleteCenter) return;
     try {
     try {
-      await deleteCostCenterMutation.mutateAsync(centerId);
+      await deleteCostCenterMutation.mutateAsync(pendingDeleteCenter.id);
       queryClient.invalidateQueries({ queryKey: ['finance'] });
       queryClient.invalidateQueries({ queryKey: ['finance'] });
-      if (selectedManageCenterId === centerId) {
+      if (selectedManageCenterId === pendingDeleteCenter.id) {
         setSelectedManageCenterId(null);
         setSelectedManageCenterId(null);
       }
       }
       showToast(t('finance.costCenterDeleted', 'Cost center deleted'));
       showToast(t('finance.costCenterDeleted', 'Cost center deleted'));
     } catch (error) {
     } catch (error) {
       showToast((error as Error).message || t('finance.costCenterDeleteFailed', 'Failed to delete cost center'), 'error');
       showToast((error as Error).message || t('finance.costCenterDeleteFailed', 'Failed to delete cost center'), 'error');
+    } finally {
+      setPendingDeleteCenter(null);
     }
     }
   };
   };
 
 
-  const handleDeleteTransaction = (transactionId: number) => {
-    const confirmed = window.confirm(
-      t('finance.deleteTransactionConfirm', 'Delete this transaction? Balances will be recalculated automatically.')
-    );
-    if (!confirmed) return;
-
-    deleteTransactionMutation.mutate(transactionId);
+  const confirmDeleteTransaction = () => {
+    if (pendingDeleteTransactionId == null) return;
+    deleteTransactionMutation.mutate(pendingDeleteTransactionId, {
+      onSettled: () => setPendingDeleteTransactionId(null),
+    });
   };
   };
 
 
   const handleEditTransaction = (tx: WalletTransaction) => {
   const handleEditTransaction = (tx: WalletTransaction) => {
@@ -645,7 +644,7 @@ export function FinancePage() {
   };
   };
 
 
   const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => {
   const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => {
-    if (center.budget_limit == null || center.budget_available == null) return '-';
+    if (center.budget_limit == null || center.budget_available == null) return t('finance.unlimited', 'Unlimited');
     return `${currencySymbol}${center.budget_available.toFixed(2)}/${currencySymbol}${center.budget_limit.toFixed(2)}`;
     return `${currencySymbol}${center.budget_available.toFixed(2)}/${currencySymbol}${center.budget_limit.toFixed(2)}`;
   };
   };
 
 
@@ -677,6 +676,9 @@ export function FinancePage() {
             <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
             <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
           </div>
           </div>
           <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
           <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
+          <p className="text-xs text-bambu-gray mt-1 max-w-2xl">
+            {t('finance.budgetPolicyHint', 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.')}
+          </p>
         </div>
         </div>
 
 
         <div className="flex flex-col gap-2 lg:items-end">
         <div className="flex flex-col gap-2 lg:items-end">
@@ -729,7 +731,7 @@ export function FinancePage() {
       <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
       <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
         <Card>
         <Card>
           <CardHeader className="flex flex-row items-center justify-between pb-2">
           <CardHeader className="flex flex-row items-center justify-between pb-2">
-            <span className="text-sm text-bambu-gray">{t('finance.currentBalance', 'Personal balance')}</span>
+            <span className="text-sm text-bambu-gray">{t('finance.currentBalance', 'Personal account balance')}</span>
             <Wallet className="w-4 h-4 text-bambu-green/90" />
             <Wallet className="w-4 h-4 text-bambu-green/90" />
           </CardHeader>
           </CardHeader>
           <CardContent className="pt-1">
           <CardContent className="pt-1">
@@ -1058,7 +1060,7 @@ export function FinancePage() {
                       <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
                       <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
                           <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
                           <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
                           {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('finance.owner', 'Owner')}</th>}
                           {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('finance.owner', 'Owner')}</th>}
-                          <th className={tableHeadCellClass}>{t('finance.balance', 'Balance')}</th>
+                          <th className={tableHeadCellClass}>{t('finance.balance', 'Account balance')}</th>
                           <th className={tableHeadCellClass}>{t('finance.budget', 'Budget')}</th>
                           <th className={tableHeadCellClass}>{t('finance.budget', 'Budget')}</th>
                           {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
                           {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
                       </tr>
                       </tr>
@@ -1103,7 +1105,7 @@ export function FinancePage() {
                                       <Button
                                       <Button
                                         size="sm"
                                         size="sm"
                                         variant="ghost"
                                         variant="ghost"
-                                        onClick={() => handleDeleteCenter(center.id, center.name)}
+                                        onClick={() => setPendingDeleteCenter({ id: center.id, name: center.name })}
                                         disabled={deleteCostCenterMutation.isPending}
                                         disabled={deleteCostCenterMutation.isPending}
                                         title={t('common.delete', 'Delete')}
                                         title={t('common.delete', 'Delete')}
                                         className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
                                         className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
@@ -1239,7 +1241,7 @@ export function FinancePage() {
                                   <Button
                                   <Button
                                     size="sm"
                                     size="sm"
                                     variant="danger"
                                     variant="danger"
-                                    onClick={() => handleDeleteTransaction(tx.id)}
+                                    onClick={() => setPendingDeleteTransactionId(tx.id)}
                                     disabled={deleteTransactionMutation.isPending}
                                     disabled={deleteTransactionMutation.isPending}
                                   >
                                   >
                                     <Trash2 className="w-4 h-4" />
                                     <Trash2 className="w-4 h-4" />
@@ -1459,6 +1461,35 @@ export function FinancePage() {
             </div>
             </div>
           </FinanceModal>
           </FinanceModal>
         )}
         )}
+
+        {pendingDeleteCenter && (
+          <ConfirmModal
+            title={t('common.delete', 'Delete')}
+            message={t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', {
+              name: pendingDeleteCenter.name,
+            })}
+            confirmText={t('common.delete', 'Delete')}
+            variant="danger"
+            isLoading={deleteCostCenterMutation.isPending}
+            onConfirm={confirmDeleteCenter}
+            onCancel={() => setPendingDeleteCenter(null)}
+          />
+        )}
+
+        {pendingDeleteTransactionId != null && (
+          <ConfirmModal
+            title={t('finance.deleteTransaction', 'Delete transaction')}
+            message={t(
+              'finance.deleteTransactionConfirm',
+              'Delete this transaction? Balances will be recalculated automatically.',
+            )}
+            confirmText={t('common.delete', 'Delete')}
+            variant="danger"
+            isLoading={deleteTransactionMutation.isPending}
+            onConfirm={confirmDeleteTransaction}
+            onCancel={() => setPendingDeleteTransactionId(null)}
+          />
+        )}
       </div>
       </div>
     </div>
     </div>
   );
   );