Просмотр исходного кода

implemented pr (worth fixing) feedback

update commit
behrinml 1 месяц назад
Родитель
Сommit
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.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.models.archive import PrintArchive
 from backend.app.models.finance import (
     BudgetReservation,
     CostCenter,
@@ -20,7 +19,6 @@ from backend.app.models.finance import (
     WalletTransaction,
     normalize_transaction_type,
 )
-from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.finance import (
@@ -44,6 +42,7 @@ from backend.app.services.finance_balance import (
     personal_balance_condition,
     sync_personal_wallet_balance,
 )
+from backend.app.services.finance_budget import get_cost_center_reserved_map
 
 router = APIRouter(prefix="/finance", tags=["finance"])
 
@@ -132,6 +131,7 @@ async def _get_cost_center_usage_maps(
         .where(
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
         )
         .group_by(WalletTransaction.cost_center_id)
     )
@@ -143,6 +143,7 @@ async def _get_cost_center_usage_maps(
         .where(
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
             WalletTransaction.created_at >= budget_window_start_utc,
         )
         .group_by(WalletTransaction.cost_center_id)
@@ -165,6 +166,7 @@ async def _get_cost_center_balance_map(
         .where(
             WalletTransaction.cost_center_id.in_(cost_center_ids),
             WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
         )
         .group_by(WalletTransaction.cost_center_id)
     )
@@ -175,31 +177,7 @@ async def _get_cost_center_reserved_map(
     db: AsyncSession,
     cost_center_ids: list[int],
 ) -> 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]:
@@ -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(
-            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)
-        .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(
@@ -356,6 +357,7 @@ async def _create_wallet_adjustment(
         result = await db.execute(
             select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
                 WalletTransaction.cost_center_id == cost_center_id,
+                WalletTransaction.is_voided.is_(False),
             )
         )
         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)
 
     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)
 
     result = await db.execute(
         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())
         .limit(limit)
         .offset(offset)
     )
     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(
         items=[
             _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."""
     await _require_authenticated_user(current_user)
 
-    conditions = []
+    conditions = [WalletTransaction.is_voided.is_(False)]
     if user_id is not None:
         await _get_user_or_404(db, 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."""
     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()
     if tx is None:
         raise HTTPException(status_code=404, detail="Transaction not found")
 
     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 _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."""
     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()
     if tx is None:
         raise HTTPException(status_code=404, detail="Transaction not found")
 
     # Apply edits
     if request.user_id is not None:
+        await _get_user_or_404(db, 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
 
     if request.amount is not None:
@@ -573,6 +588,8 @@ async def create_manual_print(
 ):
     """Create a manual print charge transaction (for admin purposes)."""
     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
 
@@ -680,7 +697,7 @@ async def get_user_transactions(
 
     result = await db.execute(
         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())
         .limit(limit)
         .offset(offset)
@@ -857,6 +874,12 @@ async def update_cost_center(
     await _require_authenticated_user(current_user)
     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:
         center.name = body.name.strip()
     if body.is_active is not None:
@@ -959,18 +982,35 @@ async def delete_cost_center(
     if center.is_private:
         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.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()
     return {"status": "success"}
 

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

@@ -30,6 +30,7 @@ EVENT_NAMES = {
     "print_failed": "Print Failed",
     "print_stopped": "Print Stopped",
     "print_progress": "Print Progress",
+    "billing_charge_failed": "Billing Charge Failed",
     "printer_offline": "Printer Offline",
     "printer_error": "Printer Error",
     "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_progress": provider.on_print_progress,
         "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
+        "on_billing_charge_failed": provider.on_billing_charge_failed,
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "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_progress=provider_data.on_print_progress,
         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
         on_printer_offline=provider_data.on_printer_offline,
         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_archive_id INTEGER REFERENCES print_archives(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,
                 CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
                     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_archive_id INTEGER REFERENCES print_archives(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,
                 CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
                     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
     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.
     if is_sqlite():
@@ -4919,39 +4945,16 @@ async def seed_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):
     """Internal helper that repairs wallet ledger using an existing session.
 
     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.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))
     centers = {
         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
     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
-    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
     # 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
 _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
 # MQTT print-complete callback awaits this task and only sends its regular
 # 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
 
 
+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:
     """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.
     """
 
-    if printer_manager.get_current_print_user(printer_id):
+    if _is_bambuddy_authorized_print_in_memory(printer_id, state):
         return True
 
     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
     # 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
     if not is_active_print:
         _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:
         kill_switch_enabled = False
         authorization: bool | None = None
         status_logger = logging.getLogger(__name__)
         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)
         except Exception as e:
             # 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",
                 printer_id,
             )
-        elif printer_id in _unauthorized_print_kill_sent:
-            pass
         else:
             try:
                 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.
     # Uses run_with_retry to handle SQLite "database is locked" errors (#897).
     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_auto_off = False
     try:
@@ -5251,6 +5291,7 @@ async def on_print_complete(printer_id: int, data: dict):
         from backend.app.models.print_queue import PrintQueueItem
 
         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
             result = await db.execute(
                 select(PrintQueueItem)
@@ -5283,6 +5324,10 @@ async def on_print_complete(printer_id: int, data: dict):
 
                 await db.commit()
                 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
                 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_archive,
                         billing_path,
+                        billing_plate_id if billing_plate_id is not None else _get_start_plate_id(archive_id),
                     )
         except Exception as 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
 
                 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:
                     archive.created_by_id = _print_user_info.get("user_id")
                     await db.flush()
@@ -5665,12 +5713,16 @@ async def on_print_complete(printer_id: int, data: dict):
                     usage_results,
                 )
                 filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None
-                cost_center_id = _print_cost_center_ids.pop(archive_id, None)
+                in_memory_cost_center_id = _print_cost_center_ids.pop(archive_id, None)
                 charged = await apply_print_charge_for_archive(
                     db,
                     archive_id,
-                    cost_center_id=cost_center_id,
-                    print_run_id=archive.subtask_id if archive else None,
+                    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,
                     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)
     except Exception as 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")
 

+ 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
     # print and keep the original row instead of cancel-then-create.
     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
     # 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(
         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)
 

+ 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_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_billing_charge_failed = Column(Boolean, default=True)  # A completed/stopped print could not be charged
 
     # Event triggers - printer status
     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",
         "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",
         "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
     )
     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)
     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,
         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
     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_progress: bool | None = None
     on_print_missing_spool_assignment: bool | None = None
+    on_billing_charge_failed: bool | None = None
 
     # Event triggers - printer status
     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_PROGRESS = "print_progress"
     PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
+    BILLING_CHARGE_FAILED = "billing_charge_failed"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_ERROR = "printer_error"
     FILAMENT_LOW = "filament_low"
@@ -71,6 +72,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "app_name",
     ],
+    "billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "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",
         "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": "Bambu X1C",
         "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:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
-            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
+            # 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
             new_progress = float(data["mc_percent"])
             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_from(WalletTransaction)
         .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)
 

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

@@ -1,4 +1,5 @@
 import logging
+import uuid
 
 from sqlalchemy import func, select
 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__)
 
 
+class BillingRunIdCollisionError(RuntimeError):
+    """A billing idempotency key points at a different physical print run."""
+
+
 async def _get_balance_after_for_transaction(
     db: AsyncSession,
     user_id: int,
@@ -43,6 +48,7 @@ async def _get_balance_after_for_transaction(
             result = await db.execute(
                 select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
                     WalletTransaction.cost_center_id == cost_center_id,
+                    WalletTransaction.is_voided.is_(False),
                 )
             )
             current_balance = float(result.scalar() or 0.0)
@@ -110,7 +116,9 @@ async def apply_print_charge_for_archive(
     db: AsyncSession,
     archive_id: int,
     *,
+    charged_user_id: int | None = None,
     cost_center_id: int | None = None,
+    print_queue_id: int | None = None,
     print_run_id: str | None = None,
     base_cost_override: float | None = None,
     filament_usage: tuple[float | None, float | None] | None = None,
@@ -124,7 +132,12 @@ async def apply_print_charge_for_archive(
     """
     try:
         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)
             return False
 
@@ -135,6 +148,10 @@ async def apply_print_charge_for_archive(
             logger.warning(f"Archive with ID {archive_id} not found.")
             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:
             logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
             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.")
             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.")
             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.")
             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()
         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}.")
+            if existing_tx.is_voided:
+                logger.info("Print charge for run %s was voided by an administrator.", effective_run_id)
             return False
 
         # Calculate charge (full for completed, partial for others)
@@ -171,62 +207,87 @@ async def apply_print_charge_for_archive(
             filament_usage=filament_usage,
         )
         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.")
             return False
 
         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:
-            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)
             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}"
         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:
             balance_after = round(float(balance_after), 2)
 
         tx = WalletTransaction(
-            user_id=archive.created_by_id,
+            user_id=actual_user_id,
             cost_center_id=actual_cost_center_id,
             transaction_type=TransactionType.PRINT_CHARGE.value,
             amount=-charge,
             balance_after=balance_after,
             description=description,
             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_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:
-            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:
-            # 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
         # center charge must not debit the user's personal wallet.
         new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
 
         # 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}.")
         return True
     except SQLAlchemyError as e:
         logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
-        return False
+        raise
     except ValueError as e:
         logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
         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 = [
         WalletTransaction.cost_center_id == cost_center_id,
         WalletTransaction.cost_center_id.is_not(None),
+        WalletTransaction.is_voided.is_(False),
     ]
     if monthly:
         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)
 
 
-async def _cost_center_open_queue_reservations(
+async def get_cost_center_reserved_map(
     db: AsyncSession,
-    cost_center_id: int,
+    cost_center_ids: list[int],
     *,
     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 = (
         select(BudgetReservation.id)
         .where(
@@ -99,38 +112,41 @@ async def _cost_center_open_queue_reservations(
         )
         .exists()
     )
-    conditions = [
-        PrintQueueItem.cost_center_id == cost_center_id,
+    queue_conditions = [
+        PrintQueueItem.cost_center_id.in_(cost_center_ids),
         PrintQueueItem.status.in_(("pending", "printing")),
         ~active_queue_reservation,
     ]
     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",
     ]
-    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(
@@ -179,17 +195,14 @@ async def validate_print_budget(
         return
 
     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,
-        cost_center_id,
+        [cost_center_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)
     available = float(budget_limit) - used - reserved
     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)
         await db.flush()
         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 = (
         await db.execute(

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

@@ -1353,6 +1353,39 @@ class NotificationService:
             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):
         """Handle printer offline event."""
         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:
         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:
         return None

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

@@ -4,6 +4,7 @@ import asyncio
 import json
 import logging
 import time
+import uuid
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from pathlib import Path
@@ -1297,7 +1298,7 @@ class PrintScheduler:
                 # reservation survives only after start_print() accepted the
                 # command. Failure, cancellation, deferral, and exceptions all
                 # 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
                 # row's status carries the lock (printing/failed/cancelled are all
                 # != pending), so the token is only needed for the duration of the
@@ -1329,37 +1330,37 @@ class PrintScheduler:
                 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:
             return
 
         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:
-                    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
-                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:
         """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
         # the file we just uploaded, do NOT send start_print.
         now_utc = datetime.now(timezone.utc)
+        billing_run_id = str(uuid.uuid4())
         cas = await db.execute(
             update(PrintQueueItem)
             .where(PrintQueueItem.id == item.id)
             .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()
         if cas.rowcount == 0:
@@ -4244,6 +4246,16 @@ class PrintScheduler:
         # item.started_at sees the values we just persisted.
         item.status = "printing"
         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:
             try:

+ 1 - 0
backend/tests/conftest.py

@@ -525,6 +525,7 @@ def notification_provider_factory(db_session):
             "on_print_stopped": True,
             "on_print_progress": False,
             "on_print_missing_spool_assignment": False,
+            "on_billing_charge_failed": True,
             "on_printer_offline": False,
             "on_printer_error": 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.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.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 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 {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.integration
     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")
         user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
         assert user is not None
+        user_id = user.id
 
         archive = PrintArchive(
             printer_id=None,
@@ -384,13 +554,15 @@ class TestFinanceAPI:
             balance_after=-4.0,
             description="Print charge: print.gcode",
             created_by_user_id=None,
+            print_run_id="deleted-print-run",
             print_archive_id=archive.id,
         )
         db_session.add(tx)
         await db_session.commit()
+        archive_id = archive.id
 
         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()
             .all()
         )
@@ -400,13 +572,43 @@ class TestFinanceAPI:
             f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
         )
         assert delete_response.status_code == 200
+        db_session.expire_all()
 
         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()
             .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(
         self,
@@ -470,6 +672,31 @@ class TestFinanceAPI:
         # Description should have "(Admin edit)" appended
         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.integration
     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
         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:
     """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}")
         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:
     """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.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.integration
     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
 from backend.app.core.database import Base
 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.printer import Printer
 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
     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
     # €4 hold. A second €6 job must fit exactly; €6.01 must not.
     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
 async def test_upload_failure_releases_scheduler_reservation(billing_dispatch_case):
     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 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 == []
 
 
+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:
     """#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."""
 
+from unittest.mock import AsyncMock
+
 import pytest
 from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
 
 from backend.app.models.archive import PrintArchive
 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.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):
@@ -20,6 +24,187 @@ async def enable_billing(db_session):
 
 
 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
     async def test_apply_print_charge_uses_print_run_id_and_cost_center_override(self, 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)
         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()
 
+    @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)
     # ========================================================================

+ 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={})
 
 
+@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
 async def test_library_estimate_uses_server_default_cost(monkeypatch, library_file):
     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",
     "budget_reservations",
     "cost_center_members",
-    "cost_center_invitations",
     "user_wallets",
 }
 
@@ -38,11 +37,22 @@ async def test_finance_tables_are_created_idempotently_on_sqlite():
                     "SELECT name FROM sqlite_master "
                     "WHERE type = 'table' AND name IN "
                     "('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 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:
         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("id SERIAL PRIMARY KEY" in sql for sql in 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 = {
         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)
 def clear_kill_switch_state():
+    main_module._kill_switch_setting_cache = None
     main_module._unauthorized_print_kill_sent.clear()
     main_module._kill_switch_notification_tasks.clear()
     main_module._expected_prints.clear()
@@ -24,6 +25,7 @@ def clear_kill_switch_state():
     main_module._active_prints.clear()
     main_module._expected_print_registered_at.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():
@@ -51,8 +53,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
     async def kill_switch_enabled(_db):
         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(
@@ -91,9 +92,11 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         gcode_file="foreign_job.gcode",
     )
 
+    await main_module.on_printer_status_change(7, state)
     await main_module.on_printer_status_change(7, state)
 
     assert stop_calls == [7]
+    unauthorized.assert_awaited_once()
     assert 7 in main_module._unauthorized_print_kill_sent
     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):
         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(
@@ -179,6 +181,26 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
 
     assert stop_calls == []
     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

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

@@ -150,6 +150,33 @@ describe('PrintModal', () => {
       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', () => {
       render(
         <PrintModal

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

@@ -192,4 +192,41 @@ describe('PrintModal billing payloads', () => {
       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 };
         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;
     },
     i18n: {},
@@ -516,6 +520,30 @@ describe('useWebSocket hook', () => {
       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 () => {
       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_progress: boolean;
   on_print_missing_spool_assignment: boolean;
+  on_billing_charge_failed: boolean;
   // Printer status events
   on_printer_offline: boolean;
   on_printer_error: boolean;
@@ -2740,6 +2741,7 @@ export interface NotificationProviderCreate {
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_missing_spool_assignment?: boolean;
+  on_billing_charge_failed?: boolean;
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
@@ -2794,6 +2796,7 @@ export interface NotificationProviderUpdate {
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_missing_spool_assignment?: boolean;
+  on_billing_charge_failed?: boolean;
   // Printer status events
   on_printer_offline?: 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 [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
   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 [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? 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_stopped: onPrintStopped,
       on_print_progress: onPrintProgress,
+      on_billing_charge_failed: onBillingChargeFailed,
       on_printer_offline: onPrinterOffline,
       on_printer_error: onPrinterError,
       on_ai_failure_detection: onAiFailureDetection,
@@ -605,6 +607,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </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>
                     <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 (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               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 (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               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 && (
               <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 && (
               <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 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>
                     <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();
 
   if (costCenters.length === 0) return null;
+  const selectedCostCenter = costCenters.find((center) => center.id === selectedCostCenterId);
 
   return (
     <div className="space-y-1">
@@ -33,6 +34,11 @@ export function CostCenterSelect({
           </option>
         ))}
       </select>
+      {selectedCostCenter?.budget_mode === 'none' && (
+        <p className="text-xs text-bambu-gray">
+          {t('printModal.unlimitedNoBudget', 'Unlimited – no budget limit is set.')}
+        </p>
+      )}
     </div>
   );
 }

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

@@ -315,7 +315,7 @@ export function PrintModal({
     queryFn: api.getPrinters,
   });
 
-  const { data: myCostCenters } = useQuery({
+  const { data: myCostCenters, isLoading: loadingCostCenters } = useQuery({
     queryKey: ['finance', 'cost-centers', 'mine'],
     queryFn: api.getMyCostCenters,
     enabled: !!user && billingEnabled,
@@ -824,6 +824,11 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
+    if (billingEnabled && selectedCostCenter == null) {
+      showToast(t('printModal.noPrintableCostCenters'), 'error');
+      return;
+    }
+
     if (
       !options?.skipFilamentCheck &&
       !settings?.disable_filament_warnings &&
@@ -1277,6 +1282,11 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
     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
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     // Both are about the single-model case. A cross-model job has no one target
@@ -1313,6 +1323,9 @@ export function PrintModal({
     perPlateReqsFailed,
     printerStatusLoading,
     isCrossModel,
+    billingEnabled,
+    loadingCostCenters,
+    selectedCostCenter,
   ]);
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
@@ -1714,6 +1727,15 @@ export function PrintModal({
                 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
                 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;
       }
 
+      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':
         debouncedInvalidate('archives');
         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',
     noCostCenters: 'Keine Kostenstellen gefunden.',
     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',
     shared: 'Geteilt',
     cannotEditPrivateCostCenter: 'Private Kostenstellen können hier nicht bearbeitet werden',
@@ -182,7 +184,7 @@ export default {
     noTransactions: 'Keine Transaktionen verfügbar.',
     noTransactionsForFilter: 'Keine Transaktionen entsprechen den ausgewählten Filtern.',
     costCenter: 'Kostenstelle',
-    balanceAfter: 'Saldo nachher',
+    balanceAfter: 'Kontostand danach',
     userWithId: 'Benutzer #{{id}}',
     partial: 'Teilweise',
     editTransaction: 'Transaktion bearbeiten',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Drucker gelöscht',
       missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
       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}})',
       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',
@@ -4893,6 +4896,8 @@ export default {
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     gcodeInjection: 'Auto-Print G-code einfügen',
     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
@@ -5813,6 +5818,8 @@ export default {
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     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',
     printFailed: 'Druck fehlgeschlagen',
     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',
     noCostCenters: 'No cost centers found.',
     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',
     shared: 'Shared',
     cannotEditPrivateCostCenter: 'Private cost centers cannot be edited here',
@@ -451,6 +453,7 @@ export default {
       printerDeleted: 'Printer deleted',
       missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
       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}})',
       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',
@@ -4936,6 +4939,8 @@ export default {
     staggerToPrinters: 'Stagger to {{count}} printers',
     gcodeInjection: 'Inject auto-print G-code',
     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
@@ -5857,6 +5862,8 @@ export default {
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     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',
     printFailed: 'Print Failed',
     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',
     noCostCenters: 'No se encontraron centros de costes.',
     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',
     shared: 'Compartido',
     cannotEditPrivateCostCenter: 'Los centros de costes privados no se pueden editar aquí',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Impresora eliminada',
       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}}',
+      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}})',
       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',
@@ -4900,6 +4903,8 @@ export default {
     staggerToPrinters: 'Escalonar en {{count}} impresoras',
     gcodeInjection: 'Inyectar G-code de impresión automática',
     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
@@ -5821,6 +5826,8 @@ export default {
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
     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',
     printFailed: 'Impresión fallida',
     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',
     noCostCenters: 'Aucun centre de coûts trouvé.',
     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',
     shared: 'Partagé',
     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',
       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}}',
+      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}})',
       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',
@@ -4882,6 +4885,8 @@ export default {
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
     gcodeInjection: 'Injecter le G-code auto-impression',
     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
@@ -5803,6 +5808,8 @@ export default {
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     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',
     printFailed: 'Impression échoué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',
     noCostCenters: 'Nessun centro di costo trovato.',
     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',
     shared: 'Condiviso',
     cannotEditPrivateCostCenter: 'I centri di costo privati non possono essere modificati qui',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Stampante eliminata',
       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}}',
+      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}})',
       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',
@@ -4881,6 +4884,8 @@ export default {
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     gcodeInjection: 'Inietta G-code auto-stampa',
     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
@@ -5802,6 +5807,8 @@ export default {
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     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',
     printFailed: 'Stampa fallita',
     printStopped: 'Stampa interrotta',

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

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

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

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

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

@@ -164,7 +164,9 @@ export default {
     costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
     noCostCenters: "Центры затрат не найдены.",
     owner: "Владелец",
-    balance: "Баланс",
+    balance: "Баланс счёта",
+    unlimited: "Без ограничений",
+    budgetPolicyHint: "Баланс счёта используется для учёта затрат. Печать ограничивается только бюджетом выбранного центра затрат; без бюджета ограничений нет.",
     budget: "Бюджет",
     shared: "Общий",
     cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
@@ -427,6 +429,7 @@ export default {
       printerDeleted: "Принтер удалён",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
       killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не удалось начислить стоимость {{filename}} на {{printer}}. Резерв бюджета сохранён; проверьте журналы сервера.',
       assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
       assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
       assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
@@ -4652,6 +4655,8 @@ export default {
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     gcodeInjection: "Добавить G-code автозапуска",
     insufficientBudget: "Недостаточно бюджета",
+    unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
+    noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
   },
   backup: {
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5519,6 +5524,8 @@ export default {
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
     missingSpoolAssignmentLabel: "Катушка не назначена",
+    billingChargeFailedLabel: 'Ошибка списания',
+    billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати',
     missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
     printFailed: "Ошибка печати",
     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',
     noCostCenters: 'Masraf merkezi bulunamadı.',
     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',
     shared: 'Paylaşılan',
     cannotEditPrivateCostCenter: 'Özel masraf merkezleri burada düzenlenemez',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: 'Yazıcı silindi',
       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}}',
+      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}})',
       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',
@@ -4870,6 +4873,8 @@ export default {
     staggerToPrinters: '{{count}} yazıcıya kademelendir',
     gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
     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
@@ -5769,6 +5774,8 @@ export default {
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
     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',
     printFailed: 'Baskı Başarısız',
     printStopped: 'Baskı Durduruldu',

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

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

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

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

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

@@ -168,7 +168,9 @@ export default {
     costCentersHint: '檢視預算限制並將成本保持在控制範圍內',
     noCostCenters: '未找到成本中心。',
     owner: '擁有者',
-    balance: '餘額',
+    balance: '帳戶餘額',
+    unlimited: '無限制',
+    budgetPolicyHint: '帳戶餘額用於記錄成本。列印僅受所選成本中心預算限制;未設定預算時不受限制。',
     budget: '預算',
     shared: '共用',
     cannotEditPrivateCostCenter: '無法在此處編輯私人成本中心',
@@ -448,6 +450,7 @@ export default {
       printerDeleted: '印表機已刪除',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
       killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 計費失敗。預算保留已保留;請檢查伺服器記錄。',
       assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
       assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
@@ -4881,6 +4884,8 @@ export default {
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     gcodeInjection: '注入自動列印G-code',
     insufficientBudget: '預算不足',
+    unlimitedNoBudget: '無限制 – 未設定預算上限。',
+    noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
   },
 
   // Backup
@@ -5802,6 +5807,8 @@ export default {
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '計費失敗',
+    billingChargeFailedDescription: '無法記錄列印費用時通知',
     missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
     printFailed: '列印失敗',
     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 { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
+import { ConfirmModal } from '../components/ConfirmModal';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { getCurrencySymbol } from '../utils/currency';
@@ -159,6 +160,8 @@ export function FinancePage() {
   const [manualPrintAmount, setManualPrintAmount] = useState('');
   const [manualPrintDescription, setManualPrintDescription] = useState('');
   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 =
     canReadAllFinance ||
@@ -440,31 +443,27 @@ export function FinancePage() {
     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 {
-      await deleteCostCenterMutation.mutateAsync(centerId);
+      await deleteCostCenterMutation.mutateAsync(pendingDeleteCenter.id);
       queryClient.invalidateQueries({ queryKey: ['finance'] });
-      if (selectedManageCenterId === centerId) {
+      if (selectedManageCenterId === pendingDeleteCenter.id) {
         setSelectedManageCenterId(null);
       }
       showToast(t('finance.costCenterDeleted', 'Cost center deleted'));
     } catch (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) => {
@@ -645,7 +644,7 @@ export function FinancePage() {
   };
 
   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)}`;
   };
 
@@ -677,6 +676,9 @@ export function FinancePage() {
             <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
           </div>
           <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 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">
         <Card>
           <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" />
           </CardHeader>
           <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">
                           <th className={tableHeadCellClass}>{t('common.name', 'Name')}</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>
                           {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
                       </tr>
@@ -1103,7 +1105,7 @@ export function FinancePage() {
                                       <Button
                                         size="sm"
                                         variant="ghost"
-                                        onClick={() => handleDeleteCenter(center.id, center.name)}
+                                        onClick={() => setPendingDeleteCenter({ id: center.id, name: center.name })}
                                         disabled={deleteCostCenterMutation.isPending}
                                         title={t('common.delete', 'Delete')}
                                         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
                                     size="sm"
                                     variant="danger"
-                                    onClick={() => handleDeleteTransaction(tx.id)}
+                                    onClick={() => setPendingDeleteTransactionId(tx.id)}
                                     disabled={deleteTransactionMutation.isPending}
                                   >
                                     <Trash2 className="w-4 h-4" />
@@ -1459,6 +1461,35 @@ export function FinancePage() {
             </div>
           </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>
   );