Browse Source

Merge pull request #1448 from behrinml/feature/billing

[Feature] Cost Centers and Billing Functionality
MartinNYHC 1 month ago
parent
commit
3cd538acb9
77 changed files with 11183 additions and 90 deletions
  1. 6 0
      backend/app/api/routes/auth.py
  2. 1044 0
      backend/app/api/routes/finance.py
  3. 1 0
      backend/app/api/routes/notification_templates.py
  4. 2 0
      backend/app/api/routes/notifications.py
  5. 163 1
      backend/app/api/routes/print_queue.py
  6. 3 0
      backend/app/api/routes/settings.py
  7. 5 0
      backend/app/api/routes/users.py
  8. 5 0
      backend/app/core/auth.py
  9. 470 8
      backend/app/core/database.py
  10. 14 0
      backend/app/core/permissions.py
  11. 459 61
      backend/app/main.py
  12. 9 0
      backend/app/models/archive.py
  13. 166 0
      backend/app/models/finance.py
  14. 1 0
      backend/app/models/notification.py
  15. 6 0
      backend/app/models/notification_template.py
  16. 10 1
      backend/app/models/print_queue.py
  17. 118 0
      backend/app/schemas/finance.py
  18. 2 0
      backend/app/schemas/notification.py
  19. 10 0
      backend/app/schemas/notification_template.py
  20. 8 0
      backend/app/schemas/print_queue.py
  21. 24 0
      backend/app/schemas/settings.py
  22. 2 0
      backend/app/services/archive.py
  23. 8 4
      backend/app/services/bambu_mqtt.py
  24. 69 0
      backend/app/services/finance_balance.py
  25. 293 0
      backend/app/services/finance_billing.py
  26. 298 0
      backend/app/services/finance_budget.py
  27. 75 0
      backend/app/services/finance_defaults.py
  28. 33 0
      backend/app/services/notification_service.py
  29. 165 0
      backend/app/services/print_cost_estimate.py
  30. 124 1
      backend/app/services/print_scheduler.py
  31. 1 0
      backend/tests/conftest.py
  32. 4 0
      backend/tests/integration/test_auth_apikey_rbac.py
  33. 1193 0
      backend/tests/integration/test_finance_api.py
  34. 94 0
      backend/tests/integration/test_ldap_provision.py
  35. 19 0
      backend/tests/integration/test_notifications_api.py
  36. 5 0
      backend/tests/integration/test_print_lifecycle.py
  37. 325 0
      backend/tests/integration/test_print_queue_api.py
  38. 318 0
      backend/tests/integration/test_scheduler_budget_reservation.py
  39. 24 0
      backend/tests/unit/services/test_bambu_mqtt.py
  40. 744 0
      backend/tests/unit/services/test_finance_service_billing.py
  41. 92 0
      backend/tests/unit/services/test_finance_service_defaults.py
  42. 34 0
      backend/tests/unit/services/test_notification_service.py
  43. 101 0
      backend/tests/unit/services/test_print_cost_estimate.py
  44. 52 0
      backend/tests/unit/test_billing_run_id_migration.py
  45. 241 0
      backend/tests/unit/test_finance_table_migration.py
  46. 468 0
      backend/tests/unit/test_printer_kill_switch.py
  47. 1 0
      backend/tests/unit/test_scheduler_cleanup_library.py
  48. 5 3
      backend/tests/unit/test_timelapse_baseline_restart_recovery.py
  49. 2 0
      frontend/src/App.tsx
  50. 27 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  51. 232 0
      frontend/src/__tests__/components/PrintModalBilling.test.tsx
  52. 55 1
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  53. 3 2
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  54. 198 0
      frontend/src/api/client.ts
  55. 10 0
      frontend/src/components/AddNotificationModal.tsx
  56. 3 1
      frontend/src/components/Layout.tsx
  57. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  58. 44 0
      frontend/src/components/PrintModal/CostCenterSelect.tsx
  59. 29 0
      frontend/src/components/PrintModal/FilamentMapping.tsx
  60. 90 2
      frontend/src/components/PrintModal/index.tsx
  61. 3 0
      frontend/src/components/PrintModal/types.ts
  62. 15 0
      frontend/src/hooks/useWebSocket.ts
  63. 116 1
      frontend/src/i18n/locales/de.ts
  64. 116 1
      frontend/src/i18n/locales/en.ts
  65. 114 0
      frontend/src/i18n/locales/es.ts
  66. 115 0
      frontend/src/i18n/locales/fr.ts
  67. 115 0
      frontend/src/i18n/locales/it.ts
  68. 115 0
      frontend/src/i18n/locales/ja.ts
  69. 115 2
      frontend/src/i18n/locales/ko.ts
  70. 115 0
      frontend/src/i18n/locales/pt-BR.ts
  71. 113 0
      frontend/src/i18n/locales/ru.ts
  72. 114 0
      frontend/src/i18n/locales/tr.ts
  73. 114 0
      frontend/src/i18n/locales/uk.ts
  74. 115 0
      frontend/src/i18n/locales/zh-CN.ts
  75. 115 0
      frontend/src/i18n/locales/zh-TW.ts
  76. 1496 0
      frontend/src/pages/FinancePage.tsx
  77. 156 1
      frontend/src/pages/SettingsPage.tsx

+ 6 - 0
backend/app/api/routes/auth.py

@@ -68,6 +68,7 @@ from backend.app.services.email_service import (
     save_smtp_settings,
     send_email,
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 _logger = logging.getLogger(__name__)
 
@@ -480,6 +481,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
                     if user and ldap_user:
                         # Update email and group mappings on each login
                         await _sync_ldap_user(db, user, ldap_user, ldap_config)
+                        # Keep finance defaults idempotently in sync for LDAP users
+                        # (wallet + private cost center + self-membership).
+                        await ensure_user_finance_defaults(db, user)
         except Exception as e:  # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant)
             import logging
 
@@ -1341,6 +1345,8 @@ async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User
         new_user.groups = list(groups_result.scalars().all())
 
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.refresh(new_user)
     logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)

+ 1044 - 0
backend/app/api/routes/finance.py

@@ -0,0 +1,1044 @@
+import calendar
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import case, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+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.finance import (
+    BudgetReservation,
+    CostCenter,
+    CostCenterMember,
+    TransactionType,
+    UserWallet,
+    WalletTransaction,
+    normalize_transaction_type,
+)
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.finance import (
+    CostCenterBudgetUpdateRequest,
+    CostCenterCreateRequest,
+    CostCenterDetailResponse,
+    CostCenterMemberRequest,
+    CostCenterMemberResponse,
+    CostCenterSummaryResponse,
+    CostCenterUpdateRequest,
+    ManualPrintRequest,
+    TransactionEditRequest,
+    WalletAdjustmentRequest,
+    WalletAdjustmentResponse,
+    WalletBalanceResponse,
+    WalletTransactionListResponse,
+    WalletTransactionResponse,
+)
+from backend.app.services.finance_balance import (
+    calculate_personal_balance,
+    is_personal_transaction,
+    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"])
+
+
+def _serialize_wallet_transaction(tx: WalletTransaction) -> WalletTransactionResponse:
+    transaction_type = (
+        tx.transaction_type.value if isinstance(tx.transaction_type, TransactionType) else tx.transaction_type
+    )
+    return WalletTransactionResponse.model_construct(
+        id=tx.id,
+        user_id=tx.user_id,
+        cost_center_id=tx.cost_center_id,
+        transaction_type=transaction_type,
+        amount=tx.amount,
+        balance_after=tx.balance_after,
+        description=tx.description,
+        created_by_user_id=tx.created_by_user_id,
+        print_run_id=tx.print_run_id,
+        print_archive_id=tx.print_archive_id,
+        print_queue_id=tx.print_queue_id,
+        created_at=tx.created_at,
+    )
+
+
+def _clamp_day(year: int, month: int, desired_day: int) -> int:
+    return min(max(1, desired_day), calendar.monthrange(year, month)[1])
+
+
+async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
+    """Resolve monthly budget window start in UTC using configurable reset day/timezone.
+
+    Defaults preserve current behavior: day=1, timezone=UTC.
+    """
+    desired_day = 1
+    tz_name = "UTC"
+
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
+    )
+    for setting in result.scalars().all():
+        if setting.key == "finance_budget_reset_day":
+            try:
+                parsed = int(setting.value)
+                if 1 <= parsed <= 31:
+                    desired_day = parsed
+            except (TypeError, ValueError):
+                pass
+        elif setting.key == "finance_budget_reset_timezone":
+            value = (setting.value or "").strip()
+            if value:
+                tz_name = value
+
+    try:
+        tz = ZoneInfo(tz_name)
+    except ZoneInfoNotFoundError:
+        tz = timezone.utc
+
+    now_local = datetime.now(tz)
+    current_month_reset_day = _clamp_day(now_local.year, now_local.month, desired_day)
+
+    if now_local.day >= current_month_reset_day:
+        start_local = datetime(now_local.year, now_local.month, current_month_reset_day, tzinfo=tz)
+    else:
+        prev_year = now_local.year
+        prev_month = now_local.month - 1
+        if prev_month == 0:
+            prev_month = 12
+            prev_year -= 1
+        prev_month_reset_day = _clamp_day(prev_year, prev_month, desired_day)
+        start_local = datetime(prev_year, prev_month, prev_month_reset_day, tzinfo=tz)
+
+    return start_local.astimezone(timezone.utc)
+
+
+async def _get_cost_center_usage_maps(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+) -> tuple[dict[int, float], dict[int, float]]:
+    if not cost_center_ids:
+        return {}, {}
+
+    spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
+
+    total_rows = await db.execute(
+        select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
+        .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)
+    )
+
+    budget_window_start_utc = await _get_budget_window_start_utc(db)
+
+    month_rows = await db.execute(
+        select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
+        .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)
+    )
+
+    total_map = {int(center_id): float(value) for center_id, value in total_rows.all() if center_id is not None}
+    month_map = {int(center_id): float(value) for center_id, value in month_rows.all() if center_id is not None}
+    return total_map, month_map
+
+
+async def _get_cost_center_balance_map(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+) -> dict[int, float]:
+    if not cost_center_ids:
+        return {}
+
+    rows = await db.execute(
+        select(WalletTransaction.cost_center_id, func.coalesce(func.sum(WalletTransaction.amount), 0.0))
+        .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)
+    )
+    return {int(center_id): float(value) for center_id, value in rows.all() if center_id is not None}
+
+
+async def _get_cost_center_reserved_map(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+) -> dict[int, float]:
+    return await get_cost_center_reserved_map(db, cost_center_ids)
+
+
+def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
+    # Monthly takes precedence if legacy data still has both set.
+    if center.monthly_budget is not None:
+        return "monthly", float(center.monthly_budget)
+    if center.total_budget is not None:
+        return "total", float(center.total_budget)
+    return "none", None
+
+
+def _to_cost_center_summary(
+    center: CostCenter,
+    *,
+    can_print: bool,
+    total_usage: float,
+    month_usage: float,
+    total_balance: float,
+    reserved: float = 0.0,
+) -> CostCenterSummaryResponse:
+    budget_mode, budget_limit = _budget_mode_and_limit(center)
+    budget_used = month_usage if budget_mode == "monthly" else total_usage if budget_mode == "total" else None
+    budget_available = (
+        max(0.0, budget_limit - budget_used - reserved)
+        if budget_limit is not None and budget_used is not None
+        else None
+    )
+
+    return CostCenterSummaryResponse(
+        id=center.id,
+        name=center.name,
+        is_private=center.is_private,
+        owner_user_id=center.owner_user_id,
+        is_active=center.is_active,
+        total_balance=total_balance,
+        total_budget=center.total_budget,
+        monthly_budget=center.monthly_budget,
+        budget_mode=budget_mode,
+        budget_limit=budget_limit,
+        budget_used=budget_used,
+        budget_available=budget_available,
+        can_print=can_print,
+    )
+
+
+async def _require_authenticated_user(current_user: User | None) -> User:
+    if current_user is None:
+        raise HTTPException(status_code=401, detail="Authentication required")
+    return current_user
+
+
+def _has_cost_center_admin_access(user: User) -> bool:
+    return user.has_any_permission(
+        Permission.COST_CENTERS_READ_ALL.value,
+        Permission.COST_CENTERS_MODIFY.value,
+        Permission.COST_CENTERS_CREATE.value,
+    )
+
+
+async def _require_cost_center_admin_access(current_user: User | None) -> User:
+    user = await _require_authenticated_user(current_user)
+    if not _has_cost_center_admin_access(user):
+        raise HTTPException(status_code=403, detail="Missing required permissions for cost center administration")
+    return user
+
+
+async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
+    result = await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))
+    wallet = result.scalar_one_or_none()
+    if wallet:
+        return wallet
+
+    wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
+    db.add(wallet)
+    await db.flush()
+    await db.refresh(wallet)
+    return wallet
+
+
+async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if user is None:
+        raise HTTPException(status_code=404, detail="User not found")
+    return user
+
+
+async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> CostCenter:
+    result = await db.execute(
+        select(CostCenter).options(selectinload(CostCenter.members)).where(CostCenter.id == cost_center_id)
+    )
+    center = result.scalar_one_or_none()
+    if center is None:
+        raise HTTPException(status_code=404, detail="Cost center not found")
+    return center
+
+
+def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
+    return WalletBalanceResponse(
+        user_id=wallet.user_id,
+        balance=wallet.balance,
+        currency=wallet.currency,
+        updated_at=wallet.updated_at,
+    )
+
+
+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.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,
+            WalletTransaction.is_voided.is_(False),
+            personal_balance_condition(user_id),
+        )
+        .subquery()
+    )
+    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(
+    db: AsyncSession,
+    *,
+    target_user_id: int,
+    actor_user_id: int,
+    amount: float,
+    transaction_type: str,
+    description: str | None,
+    cost_center_id: int | None,
+) -> WalletAdjustmentResponse:
+    transaction_type = normalize_transaction_type(transaction_type)
+
+    if cost_center_id is not None:
+        await _get_cost_center_or_404(db, cost_center_id)
+
+    wallet = await _get_or_create_wallet(db, target_user_id)
+    affects_personal_wallet = await is_personal_transaction(db, target_user_id, cost_center_id)
+
+    # Calculate balance_after for this specific transaction context
+    if cost_center_id is None:
+        # Personal transaction: validate and update user wallet
+        new_balance = wallet.balance + amount
+        if new_balance < 0:
+            raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
+        balance_after = new_balance
+    else:
+        # Cost-center transaction: validate against cost center balance only (global, not per-user)
+        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)
+        new_cc_balance = current_cc_balance + amount
+        if new_cc_balance < 0:
+            raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
+        balance_after = new_cc_balance
+        if affects_personal_wallet and wallet.balance + amount < 0:
+            raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
+
+    tx = WalletTransaction(
+        user_id=target_user_id,
+        cost_center_id=cost_center_id,
+        transaction_type=transaction_type,
+        amount=amount,
+        balance_after=balance_after,
+        description=description,
+        created_by_user_id=actor_user_id,
+    )
+    db.add(tx)
+    await db.flush()
+    await sync_personal_wallet_balance(db, wallet)
+    await db.commit()
+    await db.refresh(wallet)
+    await db.refresh(tx)
+
+    # Return appropriate balance based on transaction type
+    if affects_personal_wallet:
+        # Personal transaction: return user wallet balance
+        response_balance = _to_balance_response(wallet)
+    else:
+        # Cost-center transaction: return cost-center balance as if it were a wallet
+        response_balance = WalletBalanceResponse(
+            user_id=target_user_id,
+            balance=balance_after,
+            currency=wallet.currency,
+            updated_at=tx.created_at,
+        )
+
+    return WalletAdjustmentResponse(
+        transaction=_serialize_wallet_transaction(tx),
+        balance=response_balance,
+    )
+
+
+@router.get("/me/balance", response_model=WalletBalanceResponse)
+async def get_my_balance(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
+):
+    """Return the current user's wallet balance."""
+    user = await _require_authenticated_user(current_user)
+    return await _get_wallet_balance_read_only(db, user.id)
+
+
+@router.get("/me/transactions", response_model=WalletTransactionListResponse)
+async def get_my_transactions(
+    limit: int = Query(50, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
+):
+    """Return wallet ledger entries for the current user."""
+    user = await _require_authenticated_user(current_user)
+
+    total_result = await db.execute(
+        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, 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, [tx.id for tx in transactions])
+    return WalletTransactionListResponse(
+        items=[
+            _serialize_wallet_transaction(tx).model_copy(
+                update={"balance_after": personal_balance_map.get(tx.id, tx.balance_after)}
+            )
+            for tx in transactions
+        ],
+        total=total,
+        limit=limit,
+        offset=offset,
+    )
+
+
+@router.get("/transactions", response_model=WalletTransactionListResponse)
+async def get_all_transactions(
+    limit: int = Query(50, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    user_id: int | None = Query(None, description="Optional filter by user id"),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
+):
+    """Return wallet ledger entries across users for admin finance view."""
+    await _require_authenticated_user(current_user)
+
+    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)
+
+    total_result = await db.execute(select(func.count(WalletTransaction.id)).where(*conditions))
+    total = int(total_result.scalar_one() or 0)
+
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(*conditions)
+        .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    transactions = result.scalars().all()
+    return WalletTransactionListResponse(
+        items=[_serialize_wallet_transaction(tx) for tx in transactions],
+        total=total,
+        limit=limit,
+        offset=offset,
+    )
+
+
+async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
+    """Rebuild through the same canonical ledger repair used at startup."""
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    await repair_wallet_ledger_internal(db)
+
+
+@router.delete("/transactions/{transaction_id}")
+async def delete_transaction(
+    transaction_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """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,
+            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
+
+    # 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)
+
+    return {"status": "success"}
+
+
+@router.patch("/transactions/{transaction_id}", response_model=WalletTransactionResponse)
+async def edit_transaction(
+    transaction_id: int,
+    request: TransactionEditRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """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,
+            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 "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:
+        tx.amount = request.amount
+
+    if request.description is not None:
+        # Append "(Admin edit)" marker if not already present
+        new_desc = request.description
+        if not new_desc.endswith("(Admin edit)"):
+            new_desc = f"{new_desc} (Admin edit)"
+        tx.description = new_desc
+
+    db.add(tx)
+    await db.flush()
+
+    # Rebuild full ledger using the current session
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
+
+    return tx
+
+
+@router.post("/transactions/manual", response_model=WalletTransactionResponse)
+async def create_manual_print(
+    request: ManualPrintRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """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
+
+    # Use provided created_at or current time
+    created_at = request.created_at or datetime.now(timezone.utc)
+
+    # Ensure manual print charges are negative amounts (charges reduce wallet)
+    amount = request.amount
+    if amount > 0:
+        amount = -abs(amount)
+
+    # Create transaction
+    tx = WalletTransaction(
+        user_id=request.user_id,
+        cost_center_id=request.cost_center_id,
+        transaction_type=TransactionType.MANUAL_ADJUSTMENT.value,
+        amount=amount,
+        balance_after=None,  # Will be set by repair_wallet_ledger_internal
+        description=request.description or "Manual print charge",
+        created_by_user_id=current_user.id if current_user else None,
+        created_at=created_at,
+    )
+    db.add(tx)
+    await db.flush()
+
+    # Rebuild full ledger using the current session
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
+
+    return tx
+
+
+@router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
+async def get_my_cost_centers(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """Return private and assigned cost centers for the current user."""
+    user = await _require_authenticated_user(current_user)
+
+    result = await db.execute(
+        select(CostCenter, CostCenterMember.can_print)
+        .outerjoin(
+            CostCenterMember,
+            (CostCenterMember.cost_center_id == CostCenter.id) & (CostCenterMember.user_id == user.id),
+        )
+        .where(
+            CostCenter.is_active.is_(True),
+            or_(
+                (CostCenter.is_private.is_(True) & (CostCenter.owner_user_id == user.id)),
+                (CostCenterMember.user_id == user.id),
+            ),
+        )
+        .order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
+    )
+
+    rows = result.all()
+    centers_only = [center for center, _ in rows]
+    center_ids = [center.id for center in centers_only]
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
+    total_balance_map = await _get_cost_center_balance_map(db, center_ids)
+    reserved_map = await _get_cost_center_reserved_map(db, center_ids)
+
+    centers: list[CostCenterSummaryResponse] = []
+    for center, can_print in rows:
+        centers.append(
+            _to_cost_center_summary(
+                center,
+                can_print=True if center.is_private and center.owner_user_id == user.id else bool(can_print),
+                total_usage=total_usage_map.get(center.id, 0.0),
+                month_usage=month_usage_map.get(center.id, 0.0),
+                total_balance=total_balance_map.get(center.id, 0.0),
+                reserved=reserved_map.get(center.id, 0.0),
+            )
+        )
+
+    return centers
+
+
+@router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
+async def get_user_balance(
+    user_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
+):
+    """Return a specific user's wallet balance."""
+    await _require_authenticated_user(current_user)
+    user = await _get_user_or_404(db, user_id)
+    return await _get_wallet_balance_read_only(db, user.id)
+
+
+@router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
+async def get_user_transactions(
+    user_id: int,
+    limit: int = Query(50, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
+):
+    """Return wallet ledger entries for a specific user."""
+    await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, user_id)
+
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(WalletTransaction.user_id == user_id, WalletTransaction.is_voided.is_(False))
+        .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    return [_serialize_wallet_transaction(tx) for tx in result.scalars().all()]
+
+
+@router.post("/users/{user_id}/deposit", response_model=WalletAdjustmentResponse)
+async def deposit_user_balance(
+    user_id: int,
+    body: WalletAdjustmentRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Add funds to a user's wallet."""
+    actor = await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, user_id)
+    return await _create_wallet_adjustment(
+        db,
+        target_user_id=user_id,
+        actor_user_id=actor.id,
+        amount=body.amount,
+        transaction_type=TransactionType.DEPOSIT.value,
+        description=body.description,
+        cost_center_id=body.cost_center_id,
+    )
+
+
+@router.post("/users/{user_id}/withdraw", response_model=WalletAdjustmentResponse)
+async def withdraw_user_balance(
+    user_id: int,
+    body: WalletAdjustmentRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Withdraw funds from a user's wallet."""
+    actor = await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, user_id)
+    return await _create_wallet_adjustment(
+        db,
+        target_user_id=user_id,
+        actor_user_id=actor.id,
+        amount=-body.amount,
+        transaction_type=TransactionType.WITHDRAW.value,
+        description=body.description,
+        cost_center_id=body.cost_center_id,
+    )
+
+
+@router.post("/rebuild-balance-ledger")
+async def rebuild_balance_ledger(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Recompute balance_after for all wallet transactions.
+
+    This rebuilds the running balance for all users and cost centers.
+    - Personal transactions: unassigned plus the user's own private cost center
+    - Cost-center transactions: global running balance for the entire cost center
+    """
+    await _require_authenticated_user(current_user)
+
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    rebuilt = await repair_wallet_ledger_internal(db)
+
+    return {
+        "status": "success",
+        "transactions_rebuilt": rebuilt,
+        "message": f"Rebuilt {rebuilt} wallet ledger values",
+    }
+
+
+@router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
+async def list_cost_centers(
+    include_inactive: bool = Query(False),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """List all cost centers.
+
+    Requires admin-level finance permissions.
+    """
+    await _require_cost_center_admin_access(current_user)
+    query = select(CostCenter).order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
+    if not include_inactive:
+        query = query.where(CostCenter.is_active.is_(True))
+
+    result = await db.execute(query)
+    centers = result.scalars().all()
+    center_ids = [center.id for center in centers]
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
+    total_balance_map = await _get_cost_center_balance_map(db, center_ids)
+    reserved_map = await _get_cost_center_reserved_map(db, center_ids)
+
+    return [
+        _to_cost_center_summary(
+            center,
+            can_print=True,
+            total_usage=total_usage_map.get(center.id, 0.0),
+            month_usage=month_usage_map.get(center.id, 0.0),
+            total_balance=total_balance_map.get(center.id, 0.0),
+            reserved=reserved_map.get(center.id, 0.0),
+        )
+        for center in centers
+    ]
+
+
+@router.post("/cost-centers", response_model=CostCenterSummaryResponse)
+async def create_cost_center(
+    body: CostCenterCreateRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
+):
+    """Create a shared cost center."""
+    await _require_authenticated_user(current_user)
+    total_budget = body.total_budget
+    monthly_budget = body.monthly_budget
+    if monthly_budget is not None:
+        total_budget = None
+    elif total_budget is not None:
+        monthly_budget = None
+
+    center = CostCenter(
+        name=body.name.strip(),
+        is_active=body.is_active,
+        is_private=False,
+        owner_user_id=None,
+        total_budget=total_budget,
+        monthly_budget=monthly_budget,
+    )
+    db.add(center)
+    await db.flush()
+    await db.commit()
+    await db.refresh(center)
+
+    return _to_cost_center_summary(center, can_print=True, total_usage=0.0, month_usage=0.0, total_balance=0.0)
+
+
+@router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
+async def get_cost_center(
+    cost_center_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """Get one cost center with its memberships."""
+    await _require_cost_center_admin_access(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
+    total_balance_map = await _get_cost_center_balance_map(db, [center.id])
+    reserved_map = await _get_cost_center_reserved_map(db, [center.id])
+    summary = _to_cost_center_summary(
+        center,
+        can_print=True,
+        total_usage=total_usage_map.get(center.id, 0.0),
+        month_usage=month_usage_map.get(center.id, 0.0),
+        total_balance=total_balance_map.get(center.id, 0.0),
+        reserved=reserved_map.get(center.id, 0.0),
+    )
+    return CostCenterDetailResponse(
+        **summary.model_dump(),
+        members=[CostCenterMemberResponse.model_validate(m) for m in center.members],
+    )
+
+
+@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterSummaryResponse)
+async def update_cost_center(
+    cost_center_id: int,
+    body: CostCenterUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Update name or active-state of a cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if center.is_private:
+        raise HTTPException(
+            status_code=400,
+            detail=("Private cost centers cannot be deactivated or renamed; 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:
+        center.is_active = body.is_active
+
+    await db.flush()
+
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
+    total_balance_map = await _get_cost_center_balance_map(db, [center.id])
+    reserved_map = await _get_cost_center_reserved_map(db, [center.id])
+    return _to_cost_center_summary(
+        center,
+        can_print=True,
+        total_usage=total_usage_map.get(center.id, 0.0),
+        month_usage=month_usage_map.get(center.id, 0.0),
+        total_balance=total_balance_map.get(center.id, 0.0),
+        reserved=reserved_map.get(center.id, 0.0),
+    )
+
+
+@router.patch("/cost-centers/{cost_center_id}/budgets", response_model=CostCenterSummaryResponse)
+async def update_cost_center_budgets(
+    cost_center_id: int,
+    body: CostCenterBudgetUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Update budget values of a cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if body.monthly_budget is not None:
+        center.monthly_budget = body.monthly_budget
+        center.total_budget = None
+    elif body.total_budget is not None:
+        center.total_budget = body.total_budget
+        center.monthly_budget = None
+    else:
+        center.total_budget = None
+        center.monthly_budget = None
+    await db.flush()
+
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
+    total_balance_map = await _get_cost_center_balance_map(db, [center.id])
+    reserved_map = await _get_cost_center_reserved_map(db, [center.id])
+    return _to_cost_center_summary(
+        center,
+        can_print=True,
+        total_usage=total_usage_map.get(center.id, 0.0),
+        month_usage=month_usage_map.get(center.id, 0.0),
+        total_balance=total_balance_map.get(center.id, 0.0),
+        reserved=reserved_map.get(center.id, 0.0),
+    )
+
+
+@router.post("/cost-centers/{cost_center_id}/members", response_model=CostCenterMemberResponse)
+async def upsert_cost_center_member(
+    cost_center_id: int,
+    body: CostCenterMemberRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Assign or update a user's membership on a cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if center.is_private:
+        raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
+
+    await _get_user_or_404(db, body.user_id)
+
+    existing = await db.execute(
+        select(CostCenterMember).where(
+            CostCenterMember.cost_center_id == cost_center_id,
+            CostCenterMember.user_id == body.user_id,
+        )
+    )
+    member = existing.scalar_one_or_none()
+    if member is None:
+        member = CostCenterMember(cost_center_id=cost_center_id, user_id=body.user_id, can_print=body.can_print)
+        db.add(member)
+    else:
+        member.can_print = body.can_print
+
+    await db.flush()
+    await db.commit()
+    return CostCenterMemberResponse.model_validate(member)
+
+
+@router.delete("/cost-centers/{cost_center_id}")
+async def delete_cost_center(
+    cost_center_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Delete a shared cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if center.is_private:
+        raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
+
+    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()
+    await db.commit()
+    return {"status": "success"}
+
+
+@router.delete("/cost-centers/{cost_center_id}/members/{user_id}")
+async def remove_cost_center_member(
+    cost_center_id: int,
+    user_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Remove a user from a shared cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+    if center.is_private:
+        raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
+
+    result = await db.execute(
+        select(CostCenterMember).where(
+            CostCenterMember.cost_center_id == cost_center_id,
+            CostCenterMember.user_id == user_id,
+        )
+    )
+    member = result.scalar_one_or_none()
+    if member is None:
+        raise HTTPException(status_code=404, detail="Membership not found")
+
+    await db.delete(member)
+    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,

+ 163 - 1
backend/app/api/routes/print_queue.py

@@ -43,6 +43,7 @@ from backend.app.schemas.print_queue import (
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
+from backend.app.services.finance_budget import release_budget_reservation, validate_print_budget
 from backend.app.services.notification_service import notification_service
 from backend.app.services.print_batch import (
     BatchDispatchError,
@@ -50,6 +51,7 @@ from backend.app.services.print_batch import (
     load_progress,
     refresh_batch_status,
 )
+from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
 )
@@ -278,6 +280,55 @@ async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path |
     return None
 
 
+async def _trusted_item_estimated_cost(
+    db: AsyncSession,
+    item: PrintQueueItem,
+    *,
+    printer_id: int | None,
+    plate_id: int | None,
+    ams_mapping: list[int] | str | None,
+) -> float | None:
+    """Recompute an existing queue item's cost from its persisted source."""
+
+    if item.archive_id:
+        archive = await db.scalar(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+        return await estimate_queue_source_cost(
+            db,
+            archive=archive,
+            plate_id=plate_id,
+            ams_mapping=ams_mapping,
+            printer_id=printer_id,
+        )
+    if item.library_file_id:
+        library_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
+        return await estimate_queue_source_cost(
+            db,
+            library_file=library_file,
+            plate_id=plate_id,
+            ams_mapping=ams_mapping,
+            printer_id=printer_id,
+        )
+
+    result = await db.execute(
+        select(PrintQueueVariant, LibraryFile)
+        .join(LibraryFile, LibraryFile.id == PrintQueueVariant.library_file_id)
+        .where(PrintQueueVariant.queue_item_id == item.id)
+    )
+    estimates = [
+        await estimate_queue_source_cost(
+            db,
+            library_file=library_file,
+            plate_id=variant.plate_id,
+            ams_mapping=variant.ams_mapping,
+            printer_id=printer_id,
+        )
+        for variant, library_file in result.all()
+    ]
+    if not estimates or any(cost is None for cost in estimates):
+        return None
+    return max(cost for cost in estimates if cost is not None)
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -333,6 +384,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
+        "cost_center_id": item.cost_center_id,
+        "estimated_cost": item.estimated_cost,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
@@ -942,6 +995,43 @@ async def add_to_queue(
         if not project_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Project not found")
 
+    # Security boundary: the browser's estimated_cost is only a display hint.
+    # Budget enforcement and the persisted reservation value must be derived
+    # from the server-owned archive/library metadata and spool assignments.
+    if variant_specs:
+        variant_costs = [
+            await estimate_queue_source_cost(
+                db,
+                library_file=variant_file,
+                plate_id=spec.plate_id,
+                ams_mapping=spec.ams_mapping,
+                printer_id=data.printer_id,
+            )
+            for spec, variant_file, _model in variant_specs
+        ]
+        trusted_estimated_cost = (
+            max(cost for cost in variant_costs if cost is not None)
+            if variant_costs and all(cost is not None for cost in variant_costs)
+            else None
+        )
+    else:
+        trusted_estimated_cost = await estimate_queue_source_cost(
+            db,
+            archive=archive,
+            library_file=library_file,
+            plate_id=data.plate_id,
+            ams_mapping=data.ams_mapping,
+            printer_id=data.printer_id,
+        )
+
+    await validate_print_budget(
+        db,
+        cost_center_id=data.cost_center_id,
+        estimated_cost=trusted_estimated_cost,
+        current_user=current_user,
+        quantity=quantity,
+    )
+
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
     # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
     # per-slot filament-mapping edit was made), but the archive carries the
@@ -998,6 +1088,8 @@ async def add_to_queue(
             filament_overrides=filament_overrides_json,
             archive_id=data.archive_id,
             library_file_id=data.library_file_id,
+            cost_center_id=data.cost_center_id,
+            estimated_cost=trusted_estimated_cost,
             scheduled_time=data.scheduled_time,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
@@ -1133,6 +1225,7 @@ async def bulk_update_queue_items(
 
     updated_count = 0
     skipped_count = 0
+    validates_billing_fields = "cost_center_id" in update_data or "estimated_cost" in update_data
 
     for item in items:
         # Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
@@ -1147,7 +1240,25 @@ async def bulk_update_queue_items(
             skipped_count += 1
             continue
 
-        for field, value in update_data.items():
+        item_update_data = update_data.copy()
+        if validates_billing_fields:
+            trusted_estimated_cost = await _trusted_item_estimated_cost(
+                db,
+                item,
+                printer_id=item_update_data.get("printer_id", item.printer_id),
+                plate_id=item.plate_id,
+                ams_mapping=item.ams_mapping,
+            )
+            item_update_data["estimated_cost"] = trusted_estimated_cost
+            await validate_print_budget(
+                db,
+                cost_center_id=item_update_data.get("cost_center_id", item.cost_center_id),
+                estimated_cost=trusted_estimated_cost,
+                current_user=user,
+                exclude_queue_item_id=item.id,
+            )
+
+        for field, value in item_update_data.items():
             setattr(item, field, value)
         updated_count += 1
 
@@ -1552,6 +1663,12 @@ async def cancel_batch(
     cancelled_count = 0
     for item in pending_items:
         item.status = "cancelled"
+        await release_budget_reservation(
+            db,
+            source_type="print_queue",
+            source_id=item.id,
+            status="released",
+        )
         cancelled_count += 1
 
     batch.status = "cancelled"
@@ -1797,6 +1914,23 @@ async def update_queue_item(
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
 
+    trusted_estimated_cost = await _trusted_item_estimated_cost(
+        db,
+        item,
+        printer_id=update_data.get("printer_id", item.printer_id),
+        plate_id=update_data.get("plate_id", item.plate_id),
+        ams_mapping=update_data.get("ams_mapping", item.ams_mapping),
+    )
+    update_data["estimated_cost"] = trusted_estimated_cost
+
+    await validate_print_budget(
+        db,
+        cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
+        estimated_cost=trusted_estimated_cost,
+        current_user=user,
+        exclude_queue_item_id=item.id,
+    )
+
     # Re-check the dispatch claim right before mutating (#2615). Several awaited
     # validations ran since the guard above, and a scheduler worker may have
     # claimed the row in that gap. A fresh read (item isn't dirty yet, so no
@@ -1844,6 +1978,12 @@ async def delete_queue_item(
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
+    await release_budget_reservation(
+        db,
+        source_type="print_queue",
+        source_id=item.id,
+        status="released",
+    )
     await db.delete(item)
     await db.commit()
 
@@ -1956,6 +2096,12 @@ async def cancel_queue_item(
 
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
+    await release_budget_reservation(
+        db,
+        source_type="print_queue",
+        source_id=item.id,
+        status="released",
+    )
     await db.commit()
 
     logger.info("Cancelled queue item %s", item_id)
@@ -2120,6 +2266,22 @@ async def start_queue_item(
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
 
+    item.estimated_cost = await _trusted_item_estimated_cost(
+        db,
+        item,
+        printer_id=item.printer_id,
+        plate_id=item.plate_id,
+        ams_mapping=item.ams_mapping,
+    )
+
+    await validate_print_budget(
+        db,
+        cost_center_id=item.cost_center_id,
+        estimated_cost=item.estimated_cost,
+        current_user=user,
+        exclude_queue_item_id=item.id,
+    )
+
     # Live deficit check — re-evaluated against current spool state, so a
     # spool swap between scheduler flagging and the user clicking ▶ clears
     # the block automatically.

+ 3 - 0
backend/app/api/routes/settings.py

@@ -194,6 +194,8 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_vibration_cali",
             "default_layer_inspect",
             "default_timelapse",
+            "billing_enabled",
+            "printer_kill_switch_enabled",
             "ldap_enabled",
             "ldap_auto_provision",
             "local_login_enabled",
@@ -221,6 +223,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
+            "finance_budget_reset_day",
             "session_max_hours",
             "pipeline_max_copies",
             "preheat_max_wait_seconds",

+ 5 - 0
backend/app/api/routes/users.py

@@ -41,6 +41,7 @@ from backend.app.services.email_service import (
     get_smtp_settings,
     send_email,
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 router = APIRouter(prefix="/users", tags=["users"])
 
@@ -164,6 +165,8 @@ async def create_user(
         new_user.groups = list(groups)
 
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.refresh(new_user)
 
@@ -307,6 +310,8 @@ async def update_user(
             )
         user.groups = list(groups)
 
+    await ensure_user_finance_defaults(db, user)
+
     await db.commit()
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one()

+ 5 - 0
backend/app/core/auth.py

@@ -216,6 +216,11 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.API_KEYS_UPDATE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_READ,
+        # Finance / cost-center data has no dedicated API-key scope.
+        Permission.COST_CENTERS_READ_OWN,
+        Permission.COST_CENTERS_READ_ALL,
+        Permission.COST_CENTERS_MODIFY,
+        Permission.COST_CENTERS_CREATE,
         # GitHub backup admin + firmware OTA.
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_RESTORE,

+ 470 - 8
backend/app/core/database.py

@@ -261,6 +261,7 @@ async def init_db():
         external_link,
         filament,
         filament_sku_settings,
+        finance,
         github_backup,
         group,
         kprofile_note,
@@ -1024,6 +1025,219 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
         raise
 
 
+async def _migrate_create_finance_tables(conn) -> None:
+    """Create finance tables missing from databases that predate billing.
+
+    ``Base.metadata.create_all()`` covers fresh installs, but upgrade and
+    restore paths can run the handwritten migrations against an existing
+    PostgreSQL schema.  The finance column migrations below must therefore not
+    assume these tables already exist.
+
+    ``UserWallet`` is mapped to ``user_wallets``.
+    """
+    if is_sqlite():
+        statements = [
+            """
+            CREATE TABLE IF NOT EXISTS cost_centers (
+                id INTEGER PRIMARY KEY,
+                code VARCHAR(32) NOT NULL UNIQUE,
+                name VARCHAR(150) NOT NULL,
+                is_active BOOLEAN NOT NULL DEFAULT 1,
+                is_private BOOLEAN NOT NULL DEFAULT 0,
+                owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS user_wallets (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
+                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS cost_center_members (
+                id INTEGER PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                can_print BOOLEAN NOT NULL DEFAULT 1,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS wallet_transactions (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
+                transaction_type VARCHAR(40) NOT NULL,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
+                description TEXT,
+                created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                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')
+                )
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS budget_reservations (
+                id INTEGER PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                amount NUMERIC(14,2) NOT NULL,
+                status VARCHAR(20) NOT NULL,
+                source_type VARCHAR(50) NOT NULL,
+                source_id INTEGER,
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                released_at DATETIME
+            )
+            """,
+        ]
+    else:
+        statements = [
+            """
+            CREATE TABLE IF NOT EXISTS cost_centers (
+                id SERIAL PRIMARY KEY,
+                code VARCHAR(32) NOT NULL UNIQUE,
+                name VARCHAR(150) NOT NULL,
+                is_active BOOLEAN NOT NULL DEFAULT TRUE,
+                is_private BOOLEAN NOT NULL DEFAULT FALSE,
+                owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS user_wallets (
+                id SERIAL PRIMARY KEY,
+                user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
+                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
+                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS cost_center_members (
+                id SERIAL PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                can_print BOOLEAN NOT NULL DEFAULT TRUE,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS wallet_transactions (
+                id SERIAL PRIMARY KEY,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
+                transaction_type VARCHAR(40) NOT NULL,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
+                description TEXT,
+                created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                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')
+                )
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS budget_reservations (
+                id SERIAL PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                amount NUMERIC(14,2) NOT NULL,
+                status VARCHAR(20) NOT NULL,
+                source_type VARCHAR(50) NOT NULL,
+                source_id INTEGER,
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                released_at TIMESTAMP
+            )
+            """,
+        ]
+
+    for statement in statements:
+        await _safe_execute(conn, statement)
+
+
+async def _migrate_create_finance_indexes(conn) -> None:
+    """Create finance indexes after legacy tables have received new columns."""
+    # Older billing migrations created this as a non-unique index. Recreate it
+    # so upgraded databases enforce the same constraint as the ORM model.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS ix_cost_centers_code")
+    indexes = [
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_centers_name ON cost_centers (name)",
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_user_wallets_user_id ON user_wallets (user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_center_members_cost_center_id ON cost_center_members (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_center_members_user_id ON cost_center_members (user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_user_id ON wallet_transactions (user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_cost_center_id ON wallet_transactions (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_transaction_type ON wallet_transactions (transaction_type)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_by_user_id "
+        "ON wallet_transactions (created_by_user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_run_id ON wallet_transactions (print_run_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_archive_id ON wallet_transactions (print_archive_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_queue_id ON wallet_transactions (print_queue_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_at ON wallet_transactions (created_at)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id ON budget_reservations (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id ON budget_reservations (print_archive_id)",
+    ]
+    for statement in indexes:
+        await _safe_execute(conn, statement)
+
+
+async def _migrate_finance_money_to_numeric(conn) -> None:
+    """Convert persisted finance money columns on PostgreSQL upgrades."""
+    if is_sqlite():
+        # SQLite uses dynamic type affinity. New tables declare NUMERIC, while
+        # existing values remain protected by cent-rounding at write/rebuild.
+        return
+
+    columns = {
+        "cost_centers": ("total_budget", "monthly_budget"),
+        "user_wallets": ("balance",),
+        "wallet_transactions": ("amount", "balance_after"),
+        "budget_reservations": ("amount",),
+    }
+    for table_name, column_names in columns.items():
+        for column_name in column_names:
+            await _safe_execute(
+                conn,
+                f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
+                f"TYPE NUMERIC(14,2) USING ROUND({column_name}::numeric, 2)",
+            )
+
+
+async def _migrate_add_print_archive_cost_center(conn) -> None:
+    """Add the nullable cost-center link missing from pre-billing archives."""
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_archives ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL",
+    )
+
+
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
 
@@ -1038,6 +1252,11 @@ async def run_migrations(conn):
     """
     from sqlalchemy import text
 
+    # Existing PostgreSQL databases predate the finance ORM tables. These must
+    # exist before any ALTER TABLE / CREATE INDEX statements below reference
+    # them. Fresh installs remain idempotent because create_all() runs first.
+    await _migrate_create_finance_tables(conn)
+
     # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
     # Links a retry-failed run back to its parent so the dashboard can show
     # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
@@ -1058,6 +1277,12 @@ async def run_migrations(conn):
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
+    # Migration: Add wallet_charge_skipped column to print_archives so deleted print charges stay deleted
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT FALSE")
+
     # Migration: Add content_hash column to print_archives for duplicate detection
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")
 
@@ -1092,6 +1317,35 @@ async def run_migrations(conn):
     # Migration: Add is_deleted column to maintenance_types for soft-deletes
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
 
+    # Migration: Add cost_center columns expected by current finance model
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT FALSE")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN total_budget NUMERIC(14,2)")
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget NUMERIC(14,2)")
+    timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
+    await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN created_at {timestamp_type}")
+    await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
+
+    # Backfill empty cost center codes on upgraded databases.
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "UPDATE cost_centers SET code = lower(hex(randomblob(6))) WHERE code IS NULL OR trim(code) = ''",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "UPDATE cost_centers SET code = substr(md5(random()::text || clock_timestamp()::text), 1, 12) "
+            "WHERE code IS NULL OR btrim(code) = ''",
+        )
+
     # Migration: Add custom_interval_type column to printer_maintenance
     await _safe_execute(conn, "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")
 
@@ -1110,6 +1364,15 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")
 
+    # Migration: Add print_run_id to wallet_transactions so repeated prints of the same archive
+    # can be billed independently without mutating archive history.
+    await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN print_run_id VARCHAR(100)")
+
+    # CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
+    # Delay indexes until every legacy column they reference has been added.
+    await _migrate_finance_money_to_numeric(conn)
+    await _migrate_create_finance_indexes(conn)
+
     # Migration: Add missing-spool-assignment print-start notification toggle
     try:
         async with conn.begin_nested():
@@ -1154,6 +1417,16 @@ async def run_migrations(conn):
         "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
     )
 
+    # Migration: Add unique indexes to prevent duplicate print-charge transactions
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_print_run ON wallet_transactions (transaction_type, print_run_id)",
+    )
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive ON wallet_transactions (transaction_type, print_archive_id)",
+    )
+
     # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     if is_sqlite():
@@ -1308,6 +1581,20 @@ async def run_migrations(conn):
     # Migration: Add manual_start column to print_queue for staged prints
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")
 
+    # Migration: Add cost_center_id column to print_queue for billing metadata
+    try:
+        async with conn.begin_nested():
+            await conn.execute(
+                text(
+                    "ALTER TABLE print_queue ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL"
+                )
+            )
+    except (OperationalError, ProgrammingError):
+        pass  # Already applied
+
+    # Migration: Add cost_center_id column to print_archives for billing metadata
+    await _migrate_add_print_archive_cost_center(conn)
+
     # Migration: Add wiki_url column to maintenance_types for documentation links
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
 
@@ -1420,12 +1707,15 @@ async def run_migrations(conn):
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
+                cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
+                col_names = {col[1] for col in cols_result.fetchall()}
                 await conn.execute(
                     text("""
                     CREATE TABLE print_queue_new (
                         id INTEGER PRIMARY KEY,
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+                        cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
@@ -1441,15 +1731,26 @@ async def run_migrations(conn):
                     )
                 """)
                 )
-                await conn.execute(
-                    text("""
+                if "cost_center_id" in col_names:
+                    await conn.execute(
+                        text("""
                     INSERT INTO print_queue_new
-                    SELECT id, printer_id, archive_id, project_id, position, scheduled_time,
+                    SELECT id, printer_id, archive_id, cost_center_id, project_id, position, scheduled_time,
                            manual_start, require_previous_success, auto_off_after, ams_mapping,
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                 """)
-                )
+                    )
+                else:
+                    await conn.execute(
+                        text("""
+                    INSERT INTO print_queue_new
+                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                           manual_start, require_previous_success, auto_off_after, ams_mapping,
+                           status, started_at, completed_at, error_message, created_at
+                    FROM print_queue
+                """)
+                    )
                 await conn.execute(text("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
@@ -1653,6 +1954,8 @@ async def run_migrations(conn):
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
+                cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
+                col_names = {col[1] for col in cols_result.fetchall()}
                 await conn.execute(
                     text("""
                     CREATE TABLE print_queue_new2 (
@@ -1660,6 +1963,7 @@ async def run_migrations(conn):
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
                         library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
+                        cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
@@ -1682,17 +1986,30 @@ async def run_migrations(conn):
                     )
                 """)
                 )
-                await conn.execute(
-                    text("""
+                if "cost_center_id" in col_names:
+                    await conn.execute(
+                        text("""
                     INSERT INTO print_queue_new2
-                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                    SELECT id, printer_id, archive_id, NULL, cost_center_id, project_id, position, scheduled_time,
                            manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
                            COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
                            COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                 """)
-                )
+                    )
+                else:
+                    await conn.execute(
+                        text("""
+                    INSERT INTO print_queue_new2
+                    SELECT id, printer_id, archive_id, NULL, NULL, project_id, position, scheduled_time,
+                           manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
+                           COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
+                           COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
+                           status, started_at, completed_at, error_message, created_at
+                    FROM print_queue
+                """)
+                    )
                 await conn.execute(text("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
@@ -2649,6 +2966,39 @@ async def run_migrations(conn):
     # Migration: Auto-print G-code injection (#422)
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
 
+    # 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)",
+    )
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT 1",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT TRUE",
+        )
+
+    # 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: Add backup_spools and backup_archives columns to github_backup_config
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
@@ -4231,6 +4581,18 @@ async def seed_default_groups():
         "library:read": "library:read_own",
     }
 
+    FINANCE_PERMISSION_MIGRATION = {
+        "finance:read_own": "cost_centers:read_own",
+        "finance:read_all": "cost_centers:read_all",
+        "finance:transactions:create": "cost_centers:modify",
+        "finance:create_transactions": "cost_centers:modify",
+        "finance:createTransactions:create": "cost_centers:modify",
+        "finance:cost_centers:create": "cost_centers:create",
+        "finance:cost_centers:update": "cost_centers:modify",
+        "finance:cost_centers:assign_users": "cost_centers:modify",
+        "finance:budgets:update": "cost_centers:modify",
+    }
+
     async with async_session() as session:
         # Get existing groups
         result = await session.execute(select(Group))
@@ -4271,6 +4633,16 @@ async def seed_default_groups():
                                 "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
                             )
 
+                    for old_perm, new_perm in FINANCE_PERMISSION_MIGRATION.items():
+                        if old_perm in new_permissions:
+                            new_permissions.remove(old_perm)
+                            if new_perm not in new_permissions:
+                                new_permissions.append(new_perm)
+                            updated = True
+                            logger.info(
+                                "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
+                            )
+
                     # For Administrators, also ensure they get *_all permissions if they have any new *_own
                     if group_name == "Administrators":
                         for _own_perm, all_perm in [
@@ -4522,3 +4894,93 @@ async def seed_color_catalog():
             )
         await session.commit()
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
+
+
+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 bindparam, select
+
+    from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+    from backend.app.services.finance_balance import transaction_affects_personal_balance
+
+    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
+    }
+
+    # Build running balances per (user, cost_center_id) pair
+    cc_running_balances: dict[int, float] = {}  # cost_center_id -> running balance
+    user_personal_balances: dict[int, float] = {}  # user_id -> personal running balance
+
+    updated_count = 0
+    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.
+    wallet_result = await session.execute(select(UserWallet))
+    for wallet in wallet_result.scalars().all():
+        balance = round(user_personal_balances.get(wallet.user_id, 0.0), 2)
+        if wallet.balance != balance:
+            wallet.balance = balance
+            session.add(wallet)
+            updated_count += 1
+
+    await session.flush()
+    return updated_count

+ 14 - 0
backend/app/core/permissions.py

@@ -139,6 +139,12 @@ class Permission(StrEnum):
     STATS_READ = "stats:read"
     STATS_FILTER_BY_USER = "stats:filter_by_user"
 
+    # Cost Centers
+    COST_CENTERS_READ_OWN = "cost_centers:read_own"
+    COST_CENTERS_READ_ALL = "cost_centers:read_all"
+    COST_CENTERS_MODIFY = "cost_centers:modify"
+    COST_CENTERS_CREATE = "cost_centers:create"
+
     # System Info
     SYSTEM_READ = "system:read"
 
@@ -305,6 +311,12 @@ PERMISSION_CATEGORIES = {
         Permission.STATS_READ,
         Permission.STATS_FILTER_BY_USER,
     ],
+    "Finance": [
+        Permission.COST_CENTERS_READ_OWN,
+        Permission.COST_CENTERS_READ_ALL,
+        Permission.COST_CENTERS_MODIFY,
+        Permission.COST_CENTERS_CREATE,
+    ],
     "System": [
         Permission.SYSTEM_READ,
     ],
@@ -460,6 +472,8 @@ DEFAULT_GROUPS = {
             Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
+            # Finance - own visibility
+            Permission.COST_CENTERS_READ_OWN.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
             # Slicer Pipelines - full access

+ 459 - 61
backend/app/main.py

@@ -30,6 +30,7 @@ from backend.app.api.routes import (
     discovery,
     external_links,
     filaments,
+    finance,
     firmware,
     github_backup,
     groups,
@@ -106,6 +107,7 @@ from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.obico_detection import obico_detection_service
+from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.printer_manager import (
     init_printer_connections,
@@ -405,6 +407,9 @@ _expected_prints: dict[tuple[int, str], int] = {}
 # Used by usage tracker to map 3MF slots to physical AMS trays
 _print_ams_mappings: dict[int, list[int]] = {}
 
+# Track cost center selection for the current print run: {archive_id: cost_center_id}
+_print_cost_center_ids: dict[int, int] = {}
+
 # Track plate_id for prints from multi-plate 3MFs: {archive_id: plate_id}
 # Used by usage tracker to scope 3MF parsing to the dispatched plate (#1697).
 # Populated by direct-Print and queue dispatch paths; queue prints also have a
@@ -419,6 +424,20 @@ _last_progress_milestone: dict[int, int] = {}
 # Track whether first layer complete notification has been sent for current print
 _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.
+_kill_switch_notification_tasks: dict[int, asyncio.Task[bool]] = {}
+
 # Track HMS errors that have been notified: {printer_id: set of error codes}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
@@ -644,6 +663,190 @@ _expected_print_registered_at: dict[tuple[int, str], float] = {}
 _EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60  # 15 minutes
 _expected_prints_cleanup_task: asyncio.Task | None = None
 
+_ACTIVE_PRINT_STATES: set[str] = {"RUNNING", "PRINTING", "PAUSE"}
+
+
+def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple[int, str]]:
+    """Build filename keys for matching a printer status update to Bambuddy-owned jobs."""
+
+    possible_keys: list[tuple[int, str]] = []
+    filename = (state.gcode_file or state.current_print or "").strip()
+    subtask_name = (state.subtask_name or "").strip()
+
+    if subtask_name:
+        possible_keys.append((printer_id, subtask_name))
+        possible_keys.append((printer_id, f"{subtask_name}.3mf"))
+        possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
+
+    if filename:
+        base_name = filename.rsplit("/", 1)[-1]
+        if base_name.endswith(".gcode.3mf"):
+            root_name = base_name[: -len(".gcode.3mf")]
+            possible_keys.append((printer_id, root_name))
+            possible_keys.append((printer_id, base_name))
+            possible_keys.append((printer_id, f"{root_name}.gcode"))
+            possible_keys.append((printer_id, f"{root_name}.3mf"))
+        elif base_name.endswith(".3mf"):
+            root_name = base_name[: -len(".3mf")]
+            possible_keys.append((printer_id, root_name))
+            possible_keys.append((printer_id, base_name))
+        elif base_name.endswith(".gcode"):
+            root_name = base_name[: -len(".gcode")]
+            possible_keys.append((printer_id, root_name))
+            possible_keys.append((printer_id, f"{root_name}.3mf"))
+            possible_keys.append((printer_id, base_name))
+        else:
+            possible_keys.append((printer_id, base_name))
+            possible_keys.append((printer_id, f"{base_name}.3mf"))
+
+    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.
+
+    ``None`` means identity is not yet safe to decide. The kill switch must
+    defer in that case: stopping a print is irreversible, and the first status
+    frames after a restart may arrive before all subtask fields are populated.
+    """
+
+    if _is_bambuddy_authorized_print_in_memory(printer_id, state):
+        return True
+
+    possible_keys = _build_status_print_keys(printer_id, state)
+
+    # In-memory ownership is lost on every Bambuddy restart, so fall back to what
+    # is on disk. subtask_id is minted per print and pins the answer to the job
+    # actually running, rather than to an unrelated one that reuses a filename.
+    raw_subtask_id = getattr(state, "subtask_id", None)
+    subtask_id = str(raw_subtask_id).strip() if raw_subtask_id is not None else ""
+    if subtask_id in ("", "0"):
+        return None
+
+    from backend.app.models.archive import PrintArchive
+
+    result = await db.execute(
+        select(PrintArchive)
+        .where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.status == "printing",
+            PrintArchive.subtask_id == subtask_id,
+        )
+        .order_by(PrintArchive.created_at.desc())
+        .limit(1)
+    )
+    archive = result.scalar_one_or_none()
+
+    # An archive row on its own proves nothing: `on_print_start` archives every
+    # print it observes, including ones started from Bambu Studio or Handy, and
+    # stamps them with the same status and subtask_id. Authorizing on its mere
+    # existence would disable the kill switch the moment the 3MF finishes
+    # downloading. Only a dispatch marker Bambuddy writes itself counts —
+    # `billing_run_id` (minted per dispatch in the scheduler) or `created_by_id`
+    # (carried over from the queue item that started it).
+    if archive is not None and (archive.billing_run_id is not None or archive.created_by_id is not None):
+        # Rehydrate the fast in-memory path for subsequent status frames. Include
+        # both the archive filename and every normalized key reported by MQTT.
+        _active_prints[(printer_id, archive.filename)] = archive.id
+        for key in possible_keys:
+            _active_prints[key] = archive.id
+        return True
+
+    # No dispatch marker. Before calling this someone else's print, check whether
+    # Bambuddy has a job of its own running on this printer: a library-file
+    # dispatch has no archive at send time, and an archive created seconds later
+    # by `on_print_start` carries neither marker. The queue row, which the
+    # scheduler commits to status="printing" before the MQTT send, is the one
+    # durable record every Bambuddy print has. It cannot be tied to this
+    # subtask_id, so it is grounds to defer, never to authorize — stopping a
+    # print is irreversible, and refusing to act costs nothing but a log line.
+    from backend.app.models.print_queue import PrintQueueItem
+
+    dispatched_here = await db.scalar(
+        select(PrintQueueItem.id)
+        .where(
+            PrintQueueItem.printer_id == printer_id,
+            PrintQueueItem.status == "printing",
+        )
+        .limit(1)
+    )
+    if dispatched_here is not None:
+        return None
+
+    return False
+
+
+async def _send_kill_switch_provider_notification(
+    printer_id: int,
+    printer_name: str,
+    data: dict,
+) -> bool:
+    """Send the immediate print-stopped provider notification.
+
+    Returning a success flag lets the normal MQTT completion path retry when
+    this early notification could not be delivered.
+    """
+
+    logger = logging.getLogger(__name__)
+    try:
+        async with async_session() as db:
+            await notification_service.on_print_complete(
+                printer_id,
+                printer_name,
+                "stopped",
+                data,
+                db,
+            )
+        return True
+    except Exception as e:
+        logger.warning(
+            "[KILL SWITCH] Immediate provider notification failed for printer %s: %s",
+            printer_id,
+            e,
+        )
+        return False
+
+
+async def _kill_switch_notification_already_sent(task: asyncio.Task[bool] | None) -> bool:
+    """Wait for an immediate kill-switch notification, if one was scheduled."""
+
+    if task is None:
+        return False
+    try:
+        return await task
+    except Exception as e:
+        logging.getLogger(__name__).warning("[KILL SWITCH] Notification task failed: %s", e)
+        return False
+
 
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
@@ -716,6 +919,7 @@ def register_expected_print(
     archive_id: int,
     ams_mapping: list[int] | None = None,
     created_by_id: int | None = None,
+    cost_center_id: int | None = None,
     plate_id: int | None = None,
 ):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
@@ -729,6 +933,8 @@ def register_expected_print(
     # Store AMS mapping for usage tracking at print completion
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
+    if cost_center_id is not None:
+        _print_cost_center_ids[archive_id] = cost_center_id
     # Store plate_id for usage tracking when this is a single-plate dispatch from
     # a multi-plate 3MF — without this, the direct-Print path attributes the whole
     # file's filament total to the spool instead of just the printed plate (#1697).
@@ -831,41 +1037,6 @@ def _compute_run_filament_grams(
     return None
 
 
-def _plate_scoped_run_estimate(archive, full_path) -> tuple[float | None, float | None]:
-    """Per-run (grams, cost) scoped to the plate this run actually printed (#2614).
-
-    ``PrintArchive.filament_used_grams`` / ``.cost`` are the sum over EVERY plate of
-    the source 3MF — correct for the archive card and project rollup, but wrong for a
-    single plate dispatched from a multi-plate file: without scoping, each printed
-    plate of a 22-plate file logs the whole ~12 kg and inflates every statistic. When
-    the archive carries a ``plate_id`` and its 3MF is on disk, return that plate's
-    slicer estimate instead; cost is scaled by the plate's share of the whole so it
-    stays consistent with the scoped grams without re-doing the filament price lookup.
-    Falls back to the archive's whole-file values when there's no plate to scope to.
-    """
-    whole_grams = archive.filament_used_grams
-    if archive.plate_id is None or full_path is None or not full_path.exists():
-        return whole_grams, archive.cost
-    try:
-        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
-
-        plate_grams = extract_plate_metadata_from_3mf(full_path, archive.plate_id).filament_used_grams
-    except Exception as exc:
-        logging.getLogger(__name__).debug(
-            "[#2614] plate-scoped estimate failed for archive %s (plate %s): %s",
-            archive.id,
-            archive.plate_id,
-            exc,
-        )
-        return whole_grams, archive.cost
-    if not plate_grams or plate_grams <= 0:
-        return whole_grams, archive.cost
-    plate_cost = archive.cost
-    if archive.cost and whole_grams and whole_grams > 0:
-        plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
-    return round(plate_grams, 2), plate_cost
-
-
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     stored_ams_mapping = data.get("ams_mapping")
@@ -1318,6 +1489,94 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}"
     )
 
+    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:
+            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
+            # irreversible stop of a print whose ownership is still unknown.
+            authorization = None
+            status_logger.warning(
+                "[KILL SWITCH] Failed to reconcile print authorization for printer %s: %s", printer_id, e
+            )
+
+        if not kill_switch_enabled or authorization is True:
+            _unauthorized_print_kill_sent.discard(printer_id)
+        elif authorization is None:
+            _unauthorized_print_kill_sent.discard(printer_id)
+            status_logger.debug(
+                "[KILL SWITCH] Deferring authorization for printer %s until archive state is reconciled",
+                printer_id,
+            )
+        else:
+            try:
+                stopped = printer_manager.stop_print(printer_id)
+                if stopped:
+                    _unauthorized_print_kill_sent.add(printer_id)
+                    printer_info = printer_manager.get_printer(printer_id)
+                    printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
+                    filename = state.subtask_name or state.gcode_file or state.current_print or "Unknown"
+                    notification_data = {
+                        "status": "stopped",
+                        "filename": state.gcode_file or state.current_print or "",
+                        "subtask_name": state.subtask_name or "",
+                        "progress": state.progress,
+                        "reason": "unauthorized_print",
+                    }
+                    status_logger.warning(
+                        "[KILL SWITCH] Stopped unauthorized print on printer %s (state=%s)",
+                        printer_id,
+                        state.state,
+                    )
+                    try:
+                        await ws_manager.broadcast(
+                            {
+                                "type": "kill_switch_triggered",
+                                "printer_id": printer_id,
+                                "printer_name": printer_name,
+                                "filename": filename,
+                                "reason": "unauthorized_print",
+                            }
+                        )
+                    except Exception as e:
+                        status_logger.warning(
+                            "[KILL SWITCH] WebSocket notification failed for printer %s: %s", printer_id, e
+                        )
+
+                    previous_task = _kill_switch_notification_tasks.pop(printer_id, None)
+                    if previous_task is not None and not previous_task.done():
+                        previous_task.cancel()
+                    _kill_switch_notification_tasks[printer_id] = spawn_background_task(
+                        _send_kill_switch_provider_notification(printer_id, printer_name, notification_data),
+                        name=f"kill-switch-notification-{printer_id}",
+                    )
+                else:
+                    status_logger.warning(
+                        "[KILL SWITCH] Could not stop unauthorized print on printer %s (state=%s)",
+                        printer_id,
+                        state.state,
+                    )
+            except Exception as e:
+                status_logger.warning(
+                    "[KILL SWITCH] Failed to stop unauthorized print on printer %s: %s", printer_id, e
+                )
+
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
     try:
         printer_info = printer_manager.get_printer(printer_id)
@@ -2487,6 +2746,7 @@ async def on_print_start(printer_id: int, data: dict):
 
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
+    _kill_switch_notification_tasks.pop(printer_id, None)
 
     # #1721: drop any leftover pre-captured finish frame from a prior print
     # so a never-consumed cache entry can't bleed into the new print's photo.
@@ -4168,16 +4428,12 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat
 
 
 async def on_print_running_observed(printer_id: int, data: dict):
-    """Restart-recovery: capture a fresh timelapse baseline for a print that
-    started before Bambuddy came up.
+    """Restart-recovery for a print that started before Bambuddy came up.
 
     bambu_mqtt.py suppresses ``on_print_start`` on the first RUNNING push
     after Bambuddy startup (#1304 guard, prevents duplicate archive
-    creation). Without that path, ``_capture_timelapse_baseline_at_start``
-    never runs and ``_scan_for_timelapse_with_retries`` falls into its
-    "take baseline now" fallback at completion time — but by then the
-    printer has already uploaded the in-flight MP4, so the baseline
-    includes it and no diff ever matches (#1485 follow-up).
+    creation). This hook restores the persisted archive into ``_active_prints``
+    and captures the timelapse baseline that normally hangs off print start.
 
     Fires once per session, in lieu of on_print_start when restart-recovery
     kicks in. The printer doesn't upload the timelapse until after PRINT
@@ -4186,21 +4442,15 @@ async def on_print_running_observed(printer_id: int, data: dict):
     """
     logger = logging.getLogger(__name__)
 
-    # Avoid double-capture: on_print_start may have run earlier in this
-    # Bambuddy process if the print started AFTER startup and we crashed
-    # later in the same session. (Realistically this can't happen — the
-    # MQTT client object would have been recreated — but the cheap guard
-    # is correct regardless.)
-    if printer_id in _timelapse_baselines:
-        logger.debug(
-            "[TIMELAPSE] on_print_running_observed: baseline already present for printer %s, skipping",
-            printer_id,
-        )
-        return
-
     async with async_session() as db:
         from backend.app.models.printer import Printer
 
+        state = printer_manager.get_status(printer_id)
+        if state is not None:
+            authorization = await _is_bambuddy_authorized_print(printer_id, state, db)
+            if authorization is True:
+                logger.info("[RESTART] Restored active Bambuddy print for printer %s", printer_id)
+
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printer = result.scalar_one_or_none()
         if not printer:
@@ -4210,6 +4460,15 @@ async def on_print_running_observed(printer_id: int, data: dict):
             )
             return
 
+    # Avoid double-capture: ownership reconciliation above must still run when
+    # a baseline already exists, but the camera work itself is one-shot.
+    if printer_id in _timelapse_baselines:
+        logger.debug(
+            "[TIMELAPSE] on_print_running_observed: baseline already present for printer %s, skipping",
+            printer_id,
+        )
+        return
+
     await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
 
 
@@ -4788,6 +5047,11 @@ async def on_print_complete(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_complete started for printer %s", printer_id)
 
+    # A kill-switch stop sends its provider notification immediately. Keep the
+    # task so the later notification path can await it and avoid a duplicate;
+    # if that immediate attempt failed, the regular completion path retries.
+    kill_switch_notification_task = _kill_switch_notification_tasks.pop(printer_id, None)
+
     # Drop the 3MF download cache for this printer (#972). The print is over,
     # nothing else legitimately needs the bytes; keeping them would only risk
     # handing a stale file to the next print if it reuses the same name.
@@ -5058,6 +5322,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:
@@ -5065,6 +5333,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)
@@ -5097,6 +5366,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)
 
@@ -5205,6 +5478,29 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning("[BED-COOL] Failed to register waiter: %s", e)
 
+    # Capture the slicer estimate before usage tracking runs. The tracker may
+    # update archive.cost with this run's measured cost; billing partial runs
+    # against that already-partial value would discount the charge twice.
+    billing_planned_grams: float | None = None
+    billing_base_cost: float | None = None
+    if archive_id:
+        try:
+            async with async_session() as db:
+                from backend.app.models.archive import PrintArchive
+
+                billing_archive = await db.get(PrintArchive, archive_id)
+                if billing_archive:
+                    billing_path = (
+                        app_settings.base_dir / billing_archive.file_path if billing_archive.file_path else None
+                    )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
+                    billing_planned_grams, billing_base_cost = _plate_scoped_run_estimate(
+                        billing_archive,
+                        billing_path,
+                        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)
+
     # --- Track filament consumption (must run before archive_id early-return so usage
     # is recorded even when auto-archive is disabled) ---
     usage_results: list[dict] = []
@@ -5346,9 +5642,12 @@ async def on_print_complete(printer_id: int, data: dict):
                     logger.info(
                         "[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps
                     )
-                    await notification_service.on_print_complete(
-                        printer_id, p_name, ps, data, db, archive_data=no_archive_data
-                    )
+                    if not await _kill_switch_notification_already_sent(kill_switch_notification_task):
+                        await notification_service.on_print_complete(
+                            printer_id, p_name, ps, data, db, archive_data=no_archive_data
+                        )
+                    else:
+                        logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification")
 
                     # Send user-specific email if we have a created_by_id
                     if no_archive_data and no_archive_data.get("created_by_id"):
@@ -5427,6 +5726,100 @@ async def on_print_complete(printer_id: int, data: dict):
 
     log_timing("Archive status update")
 
+    # Apply finance wallet charge or release reservations once. For all partial
+    # terminal states (failed, aborted at the printer display, or cancelled via
+    # Bambuddy) use this run's measured spool delta, falling back to the last
+    # valid printer progress. PrintArchive.filament_used_grams is the slicer
+    # estimate and therefore cannot represent an interrupted run.
+    try:
+        if data.get("status") in ("completed", "failed", "aborted", "cancelled"):
+            async with async_session() as db:
+                from backend.app.models.archive import PrintArchive
+                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()
+
+                run_status = data.get("status", "completed")
+                last_progress = data.get("last_progress")
+                if last_progress is None:
+                    last_progress = data.get("progress")
+                actual_run_grams = _compute_run_filament_grams(
+                    run_status,
+                    billing_planned_grams,
+                    last_progress,
+                    usage_results,
+                )
+                filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None
+                in_memory_cost_center_id = _print_cost_center_ids.pop(archive_id, None)
+                charged = await apply_print_charge_for_archive(
+                    db,
+                    archive_id,
+                    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,
+                )
+                await db.commit()
+                if charged:
+                    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")
+
     # Write independent print log entry (separate table, never touches archives)
     try:
         async with async_session() as db:
@@ -5466,7 +5859,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 _run_grams = _compute_run_filament_grams(
                     _run_status,
                     _est_grams,
-                    data.get("progress"),
+                    data.get("last_progress", data.get("progress")),
                     usage_results,
                 )
 
@@ -5984,9 +6377,12 @@ async def on_print_complete(printer_id: int, data: dict):
                             except Exception as e:
                                 logger.warning("[NOTIFY-BG] Failed to read finish photo bytes: %s", e)
 
-                await notification_service.on_print_complete(
-                    printer_id, printer_name, print_status, data, db, archive_data=archive_data
-                )
+                if not await _kill_switch_notification_already_sent(kill_switch_notification_task):
+                    await notification_service.on_print_complete(
+                        printer_id, printer_name, print_status, data, db, archive_data=archive_data
+                    )
+                else:
+                    logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification")
 
                 # Send user-specific email notification
                 if archive_data:
@@ -6907,6 +7303,7 @@ def _evict_stale_expected_prints() -> None:
     for archive_id in evicted_archive_ids:
         if archive_id not in live_archive_ids:
             _print_ams_mappings.pop(archive_id, None)
+            _print_cost_center_ids.pop(archive_id, None)
             _print_plate_ids.pop(archive_id, None)
 
     logging.getLogger(__name__).info(
@@ -7954,6 +8351,7 @@ app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
+app.include_router(finance.router, prefix=app_settings.api_prefix)
 app.include_router(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(labels.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)

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

@@ -18,6 +18,9 @@ class PrintArchive(Base):
     library_file_id: Mapped[int | None] = mapped_column(
         ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
     )
+    cost_center_id: Mapped[int | None] = mapped_column(
+        ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
+    )
 
     # File info
     filename: Mapped[str] = mapped_column(String(255))
@@ -71,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
@@ -92,6 +98,7 @@ class PrintArchive(Base):
 
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
+    wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False)
     tags: Mapped[str | None] = mapped_column(Text)
     notes: Mapped[str | None] = mapped_column(Text)
     cost: Mapped[float | None] = mapped_column(Float)
@@ -122,9 +129,11 @@ class PrintArchive(Base):
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     project: Mapped["Project | None"] = relationship(back_populates="archives")
+    cost_center: Mapped["CostCenter | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
 
 
+from backend.app.models.finance import CostCenter  # noqa: E402, F811
 from backend.app.models.printer import Printer  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.user import User  # noqa: E402, F811

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

@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+from enum import Enum as PyEnum
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.user import User
+
+
+class TransactionType(str, PyEnum):
+    PRINT_CHARGE = "print_charge"
+    DEPOSIT = "deposit"
+    WITHDRAW = "withdraw"
+    MANUAL_ADJUSTMENT = "manual_adjustment"
+
+
+VALID_TRANSACTION_TYPES = {item.value for item in TransactionType}
+
+
+def normalize_transaction_type(value: str | TransactionType) -> str:
+    if isinstance(value, TransactionType):
+        return value.value
+    if value not in VALID_TRANSACTION_TYPES:
+        raise ValueError(f"Invalid transaction type: {value}")
+    return value
+
+
+class UserWallet(Base):
+    """Per-user wallet balance.
+
+    Balance updates are driven by wallet transactions.
+    """
+
+    __tablename__ = "user_wallets"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
+    balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
+    currency: Mapped[str] = mapped_column(String(3), default="EUR")
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    user: Mapped[User] = relationship()
+
+
+class CostCenter(Base):
+    """Cost center for assigning print costs and budgets."""
+
+    __tablename__ = "cost_centers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    code: Mapped[str] = mapped_column(String(32), unique=True, index=True, default=lambda: uuid.uuid4().hex[:12])
+    name: Mapped[str] = mapped_column(String(150), index=True)
+    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+    is_private: Mapped[bool] = mapped_column(Boolean, default=False)
+    owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    total_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+    monthly_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    owner: Mapped[User | None] = relationship()
+    members: Mapped[list[CostCenterMember]] = relationship(
+        "CostCenterMember",
+        back_populates="cost_center",
+        cascade="all, delete-orphan",
+        lazy="selectin",
+    )
+
+
+class CostCenterMember(Base):
+    """User-to-cost-center assignment with print permission."""
+
+    __tablename__ = "cost_center_members"
+    __table_args__ = (UniqueConstraint("cost_center_id", "user_id", name="uq_cost_center_members_cc_user"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    can_print: Mapped[bool] = mapped_column(Boolean, default=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    cost_center: Mapped[CostCenter] = relationship("CostCenter", back_populates="members")
+    user: Mapped[User] = relationship()
+
+
+class BudgetReservation(Base):
+    """Persisted budget hold for accepted print work that has not been charged yet."""
+
+    __tablename__ = "budget_reservations"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
+    amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
+    status: Mapped[str] = mapped_column(String(20), default="active", index=True)
+    source_type: Mapped[str] = mapped_column(String(50), index=True)
+    source_id: Mapped[int | None] = mapped_column(index=True)
+    print_archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    released_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    cost_center: Mapped[CostCenter] = relationship()
+    print_archive: Mapped[PrintArchive | None] = relationship()
+
+
+class WalletTransaction(Base):
+    """Immutable wallet ledger entry."""
+
+    __tablename__ = "wallet_transactions"
+    __table_args__ = (
+        CheckConstraint(
+            "transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')",
+            name="ck_wallet_transactions_transaction_type",
+        ),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    cost_center_id: Mapped[int | None] = mapped_column(
+        ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+
+    transaction_type: Mapped[str] = mapped_column(String(40), index=True)
+    amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
+    balance_after: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    created_by_user_id: Mapped[int | None] = mapped_column(
+        ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    print_run_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
+    print_archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    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)
+
+    user: Mapped[User] = relationship(foreign_keys=[user_id])
+    cost_center: Mapped[CostCenter | None] = relationship()
+    created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
+    print_archive: Mapped[PrintArchive | None] = relationship()
+    print_queue: Mapped[PrintQueueItem | None] = relationship()
+
+    @validates("transaction_type")
+    def _validate_transaction_type(self, key: str, value: str | TransactionType) -> str:
+        return normalize_transaction_type(value)

+ 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",

+ 10 - 1
backend/app/models/print_queue.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -32,6 +32,13 @@ class PrintQueueItem(Base):
     library_file_id: Mapped[int | None] = mapped_column(
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
     )
+    cost_center_id: Mapped[int | None] = mapped_column(
+        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)
 
@@ -162,6 +169,7 @@ class PrintQueueItem(Base):
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
+    cost_center: Mapped["CostCenter | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
@@ -244,6 +252,7 @@ class PrintQueueVariant(Base):
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.finance import CostCenter  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402

+ 118 - 0
backend/app/schemas/finance.py

@@ -0,0 +1,118 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class WalletBalanceResponse(BaseModel):
+    user_id: int
+    balance: float
+    currency: str
+    updated_at: datetime | None = None
+
+
+class WalletTransactionResponse(BaseModel):
+    id: int
+    user_id: int
+    cost_center_id: int | None = None
+    transaction_type: Literal["print_charge", "deposit", "withdraw", "manual_adjustment"]
+    amount: float
+    balance_after: float | None = None
+    description: str | None = None
+    created_by_user_id: int | None = None
+    print_run_id: str | None = None
+    print_archive_id: int | None = None
+    print_queue_id: int | None = None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class WalletTransactionListResponse(BaseModel):
+    items: list[WalletTransactionResponse]
+    total: int
+    limit: int
+    offset: int
+
+
+class CostCenterSummaryResponse(BaseModel):
+    id: int
+    name: str
+    is_private: bool
+    owner_user_id: int | None = None
+    is_active: bool
+    total_balance: float = 0.0
+    total_budget: float | None = None
+    monthly_budget: float | None = None
+    budget_mode: str = "none"
+    budget_limit: float | None = None
+    budget_used: float | None = None
+    budget_available: float | None = None
+    can_print: bool = True
+
+    class Config:
+        from_attributes = True
+
+
+class WalletAdjustmentRequest(BaseModel):
+    amount: float = Field(..., gt=0)
+    description: str | None = None
+    cost_center_id: int | None = None
+
+
+class WalletAdjustmentResponse(BaseModel):
+    transaction: WalletTransactionResponse
+    balance: WalletBalanceResponse
+
+
+class TransactionEditRequest(BaseModel):
+    user_id: int | None = None
+    cost_center_id: int | None = None
+    amount: float | None = None
+    description: str | None = None
+
+
+class ManualPrintRequest(BaseModel):
+    user_id: int
+    cost_center_id: int
+    amount: float = Field(..., gt=0)
+    description: str | None = None
+    created_at: datetime | None = None
+
+
+class CostCenterCreateRequest(BaseModel):
+    name: str = Field(..., min_length=1, max_length=150)
+    total_budget: float | None = Field(default=None, ge=0)
+    monthly_budget: float | None = Field(default=None, ge=0)
+    is_active: bool = True
+
+
+class CostCenterUpdateRequest(BaseModel):
+    name: str | None = Field(default=None, min_length=1, max_length=150)
+    is_active: bool | None = None
+
+
+class CostCenterBudgetUpdateRequest(BaseModel):
+    total_budget: float | None = Field(default=None, ge=0)
+    monthly_budget: float | None = Field(default=None, ge=0)
+
+
+class CostCenterMemberRequest(BaseModel):
+    user_id: int
+    can_print: bool = True
+
+
+class CostCenterMemberResponse(BaseModel):
+    id: int
+    cost_center_id: int
+    user_id: int
+    can_print: bool
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class CostCenterDetailResponse(CostCenterSummaryResponse):
+    members: list[CostCenterMemberResponse] = []

+ 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",

+ 8 - 0
backend/app/schemas/print_queue.py

@@ -114,6 +114,8 @@ class PrintQueueItemCreate(BaseModel):
     batch_id: int | None = None
     # Project to associate the resulting archive with
     project_id: int | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
@@ -149,6 +151,8 @@ class PrintQueueItemUpdate(BaseModel):
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
     # physical nozzle position IDs from BambuStudio's project_file MQTT
     # body; sent back to the printer verbatim on dispatch.
@@ -174,6 +178,8 @@ class PrintQueueItemResponse(BaseModel):
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     position: int
     scheduled_time: UTCDatetime
     require_previous_success: bool
@@ -321,6 +327,8 @@ class PrintQueueBulkUpdate(BaseModel):
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
 
 
 class PrintQueueBulkUpdateResponse(BaseModel):

+ 24 - 0
backend/app/schemas/settings.py

@@ -381,6 +381,26 @@ class AppSettings(BaseModel):
         default=5, ge=1, le=60, description="Minutes between staggered printer groups"
     )
 
+    # Finance budget window settings
+    billing_enabled: bool = Field(
+        default=False,
+        description="Enable cost-center billing enforcement for print and queue operations",
+    )
+    printer_kill_switch_enabled: bool = Field(
+        default=False,
+        description="Immediately stop printer jobs that start without Bambuddy authorization",
+    )
+    finance_budget_reset_day: int = Field(
+        default=1,
+        ge=1,
+        le=31,
+        description="Day of month when monthly finance budget window resets (1-31, clamped for short months)",
+    )
+    finance_budget_reset_timezone: str = Field(
+        default="UTC",
+        description="IANA timezone for finance monthly budget reset calculation (e.g., Europe/Berlin)",
+    )
+
     # Plate-clear confirmation for queue scheduling
     require_plate_clear: bool = Field(
         default=False,
@@ -635,6 +655,10 @@ class AppSettingsUpdate(BaseModel):
     default_nozzle_offset_cali: TriState | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
+    billing_enabled: bool | None = None
+    printer_kill_switch_enabled: bool | None = None
+    finance_budget_reset_day: int | None = Field(default=None, ge=1, le=31)
+    finance_budget_reset_timezone: str | None = None
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
     queue_max_concurrent_uploads: int | None = Field(default=None, ge=1, le=16)

+ 2 - 0
backend/app/services/archive.py

@@ -1140,6 +1140,7 @@ class ArchiveService:
         created_by_id: int | None = None,
         original_filename: str | None = None,
         project_id: int | None = None,
+        cost_center_id: int | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
@@ -1354,6 +1355,7 @@ class ArchiveService:
             created_by_id=created_by_id,
             project_id=project_id,
             library_file_id=library_file_id,
+            cost_center_id=cost_center_id,
             subtask_id=subtask_id,
             plate_id=plate_id,
         )

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

@@ -3207,11 +3207,15 @@ 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)
-            if self.state.progress > 0:
-                self._last_valid_progress = self.state.progress
+            # 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
-            self.state.progress = float(data["mc_percent"])
+            new_progress = float(data["mc_percent"])
+            if new_progress > 0:
+                self._last_valid_progress = new_progress
+            self.state.progress = new_progress
             # #2547: strictly-increasing only. The firmware resets progress to 0
             # on cancel and re-reports the same percent on most frames; neither
             # is the print advancing, and both would make the frame bank grab a

+ 69 - 0
backend/app/services/finance_balance.py

@@ -0,0 +1,69 @@
+"""Canonical definition and synchronization of a user's personal balance."""
+
+from sqlalchemy import and_, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+
+
+def transaction_affects_personal_balance(
+    user_id: int,
+    cost_center_id: int | None,
+    *,
+    is_private: bool = False,
+    owner_user_id: int | None = None,
+) -> bool:
+    """Apply the canonical definition to already-loaded transaction data."""
+
+    return cost_center_id is None or (is_private and owner_user_id == user_id)
+
+
+def personal_balance_condition(user_id: int):
+    """Return the SQL condition for transactions in a personal wallet.
+
+    Unassigned transactions and transactions assigned to the user's own
+    private cost center are personal. Shared cost centers are not.
+    """
+
+    return or_(
+        WalletTransaction.cost_center_id.is_(None),
+        and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
+    )
+
+
+async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
+    result = await db.execute(
+        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,
+            WalletTransaction.is_voided.is_(False),
+            personal_balance_condition(user_id),
+        )
+    )
+    return round(float(result.scalar_one() or 0.0), 2)
+
+
+async def is_personal_transaction(db: AsyncSession, user_id: int, cost_center_id: int | None) -> bool:
+    if cost_center_id is None:
+        return True
+    result = await db.execute(
+        select(CostCenter.is_private, CostCenter.owner_user_id).where(CostCenter.id == cost_center_id)
+    )
+    center = result.one_or_none()
+    if center is None:
+        return False
+    return transaction_affects_personal_balance(
+        user_id,
+        cost_center_id,
+        is_private=bool(center.is_private),
+        owner_user_id=center.owner_user_id,
+    )
+
+
+async def sync_personal_wallet_balance(db: AsyncSession, wallet: UserWallet) -> float:
+    balance = await calculate_personal_balance(db, wallet.user_id)
+    wallet.balance = balance
+    db.add(wallet)
+    return balance

+ 293 - 0
backend/app/services/finance_billing.py

@@ -0,0 +1,293 @@
+import logging
+import uuid
+
+from sqlalchemy import func, select
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
+from backend.app.services.finance_balance import sync_personal_wallet_balance
+from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
+
+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,
+    cost_center_id: int | None,
+    amount: float,
+) -> float:
+    """Calculate balance_after for a transaction.
+
+    For cost-center transactions: sum of ALL transactions for that cost center (global).
+    For personal transactions (cost_center_id=None): user's wallet balance (personal).
+
+    Args:
+        user_id: The user making the transaction
+        cost_center_id: The cost center (None for personal)
+        amount: The transaction amount (positive/negative)
+
+    Returns:
+        The balance after this transaction would be applied
+    """
+    try:
+        if cost_center_id is None:
+            # Personal transaction: use user wallet balance
+            wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))).scalar_one_or_none()
+            if wallet is None:
+                return float(amount)
+            return float(wallet.balance) + amount
+        else:
+            # Cost-center transaction: sum of ALL transactions for this cost center (global, not per-user)
+            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)
+            return current_balance + amount
+    except SQLAlchemyError as e:
+        logger.error(f"Database error in _get_balance_after_for_transaction: {e}", exc_info=True)
+        raise
+
+
+def _calculate_partial_charge(
+    archive: PrintArchive,
+    base_cost: float,
+    *,
+    filament_usage: tuple[float | None, float | None] | None = None,
+) -> tuple[float, str]:
+    """Calculate proportional charge for partial prints based on filament usage.
+
+    Returns (charge_amount, description_suffix) where:
+    - charge_amount: absolute cost to charge (0 if insufficient data)
+    - description_suffix: reason/details for transaction description
+    """
+    try:
+        # Only apply proportional calculation for non-completed prints
+        if archive.status == "completed":
+            return round(float(base_cost), 2), ""
+
+        if filament_usage is not None:
+            actual_grams, planned_grams = filament_usage
+            filament_used = float(actual_grams or 0.0)
+            filament_planned = float(planned_grams) if planned_grams is not None else None
+        else:
+            # Backwards-compatible fallback for recalculation and callers that
+            # do not have per-run telemetry. At print completion main.py passes
+            # the measured/progress-scaled run usage explicitly: the archive
+            # field is the slicer's planned amount and must not be mistaken for
+            # the amount consumed by an aborted run.
+            filament_used = float(archive.filament_used_grams or 0.0)
+            filament_planned = None
+
+            if archive.extra_data and isinstance(archive.extra_data, dict):
+                filament_planned = archive.extra_data.get("filament_grams_total")
+                if filament_planned is not None:
+                    filament_planned = float(filament_planned)
+
+        # If we don't have reliable planned filament data, do not guess a partial charge.
+        # Charging a failed/aborted print without an estimated baseline can overcharge users.
+        if filament_planned is None or filament_planned <= 0:
+            return 0.0, f"[{archive.status}: insufficient filament data]"
+
+        # Calculate proportional cost
+        filament_ratio = min(1.0, max(0.0, filament_used / filament_planned))  # Clamp to [0, 1]
+        charge = float(base_cost) * filament_ratio
+
+        # Round charges to 2 decimals for consistent persistence
+        charge = round(charge, 2)
+
+        suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
+        return charge, suffix
+    except ValueError as e:
+        logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
+        raise
+
+
+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,
+) -> bool:
+    """Apply an idempotent wallet charge for a print archive.
+
+    Charges completed prints at full cost, and partial/failed prints proportionally
+    based on actual filament used vs. planned filament.
+
+    Returns True when a new wallet transaction was created.
+    """
+    try:
+        if not await is_billing_enabled(db):
+            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
+
+        archive = (
+            await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
+        ).scalar_one_or_none()
+        if archive is None:
+            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
+
+        # Accept completed, aborted, cancelled, and failed prints
+        if archive.status not in ("completed", "aborted", "cancelled", "failed"):
+            logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
+            return False
+
+        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
+
+        base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
+        if base_cost <= 0:
+            logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
+            return False
+
+        # 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)
+        charge, reason_suffix = _calculate_partial_charge(
+            archive,
+            base_cost,
+            filament_usage=filament_usage,
+        )
+        if charge <= 0:
+            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 == actual_user_id))).scalar_one_or_none()
+        if wallet is None:
+            wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
+            db.add(wallet)
+            await db.flush()
+            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, 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=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=effective_run_id,
+            print_archive_id=archive.id,
+            print_queue_id=print_queue_id,
+        )
+        # 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:
+            async with db.begin_nested():
+                db.add(tx)
+                # Flush inside the savepoint to detect unique/index conflicts.
+                await db.flush()
+        except IntegrityError as e:
+            # 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
+        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)
+        raise
+    except ValueError as e:
+        logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
+        return False

+ 298 - 0
backend/app/services/finance_budget.py

@@ -0,0 +1,298 @@
+"""Budget validation helpers for finance-aware print dispatch."""
+
+import calendar
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from fastapi import HTTPException
+from sqlalchemy import case, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.finance import BudgetReservation, CostCenter, CostCenterMember, WalletTransaction
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+async def is_billing_enabled(db: AsyncSession) -> bool:
+    # Consider any 'billing_enabled' setting with a true-ish value as enabling billing.
+    result = await db.execute(
+        select(func.count())
+        .select_from(Settings)
+        .where(Settings.key == "billing_enabled", func.lower(func.coalesce(Settings.value, "")) == "true")
+    )
+    count = int(result.scalar_one() or 0)
+    return count > 0
+
+
+async def is_printer_kill_switch_enabled(db: AsyncSession) -> bool:
+    """Return True when billing and the printer kill-switch are both enabled."""
+
+    result = await db.execute(
+        select(Settings.key, Settings.value).where(Settings.key.in_(("billing_enabled", "printer_kill_switch_enabled")))
+    )
+    values = {key: (value or "").strip().lower() for key, value in result.all()}
+    return values.get("billing_enabled") == "true" and values.get("printer_kill_switch_enabled") == "true"
+
+
+async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
+    )
+    values = {setting.key: setting.value for setting in result.scalars().all()}
+
+    desired_day = 1
+    try:
+        parsed = int(values.get("finance_budget_reset_day") or 1)
+        if 1 <= parsed <= 31:
+            desired_day = parsed
+    except (TypeError, ValueError):
+        pass
+
+    timezone_name = values.get("finance_budget_reset_timezone") or "UTC"
+    try:
+        tz = ZoneInfo(timezone_name)
+    except ZoneInfoNotFoundError:
+        tz = ZoneInfo("UTC")
+
+    now = datetime.now(tz)
+    current_month_reset_day = min(desired_day, calendar.monthrange(now.year, now.month)[1])
+    if now.day < current_month_reset_day:
+        month = now.month - 1
+        year = now.year
+        if month == 0:
+            month = 12
+            year -= 1
+    else:
+        month = now.month
+        year = now.year
+
+    reset_day = min(desired_day, calendar.monthrange(year, month)[1])
+    return datetime(year, month, reset_day, tzinfo=tz).astimezone(timezone.utc)
+
+
+async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly: bool) -> float:
+    spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
+    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))
+
+    result = await db.execute(select(func.coalesce(func.sum(spend_expr), 0.0)).where(*conditions))
+    return float(result.scalar() or 0.0)
+
+
+async def get_cost_center_reserved_map(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+    *,
+    exclude_queue_item_id: int | None = None,
+    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(
+            BudgetReservation.status == "active",
+            BudgetReservation.source_type == "print_queue",
+            BudgetReservation.source_id == PrintQueueItem.id,
+        )
+        .exists()
+    )
+    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:
+        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}
+
+    reservation_conditions = [
+        BudgetReservation.cost_center_id.in_(cost_center_ids),
+        BudgetReservation.status == "active",
+    ]
+    if exclude_reservation_source_type is not None and exclude_reservation_source_id is not None:
+        reservation_conditions.append(
+            ~(
+                (BudgetReservation.source_type == exclude_reservation_source_type)
+                & (BudgetReservation.source_id == exclude_reservation_source_id)
+            )
+        )
+    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(
+    db: AsyncSession,
+    *,
+    cost_center_id: int | None,
+    estimated_cost: float | None,
+    current_user: User | None,
+    quantity: int = 1,
+    exclude_queue_item_id: int | None = None,
+    exclude_reservation_source_type: str | None = None,
+    exclude_reservation_source_id: int | None = None,
+) -> None:
+    """Validate that a print can be assigned to a cost center budget."""
+    if not await is_billing_enabled(db):
+        return
+
+    if cost_center_id is None:
+        raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
+
+    if estimated_cost is None or estimated_cost <= 0:
+        raise HTTPException(status_code=400, detail="Estimated cost is required for cost center prints")
+
+    center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id).with_for_update())
+    if not center:
+        raise HTTPException(status_code=404, detail="Cost center not found")
+    if not center.is_active:
+        raise HTTPException(status_code=400, detail="Cost center is inactive")
+
+    if current_user is not None and not current_user.is_admin:
+        if center.is_private:
+            if center.owner_user_id != current_user.id:
+                raise HTTPException(status_code=403, detail="You cannot print with this private cost center")
+        else:
+            member = await db.scalar(
+                select(CostCenterMember).where(
+                    CostCenterMember.cost_center_id == cost_center_id,
+                    CostCenterMember.user_id == current_user.id,
+                )
+            )
+            if not member or not member.can_print:
+                raise HTTPException(status_code=403, detail="You cannot print with this cost center")
+
+    budget_limit = center.monthly_budget if center.monthly_budget is not None else center.total_budget
+    if budget_limit is None:
+        return
+
+    used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
+    reserved_map = await get_cost_center_reserved_map(
+        db,
+        [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 = reserved_map.get(cost_center_id, 0.0)
+    requested = estimated_cost * max(1, quantity)
+    available = float(budget_limit) - used - reserved
+    if requested > available:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Estimated print cost exceeds available cost center budget ({requested:.2f} > {available:.2f})",
+        )
+
+
+async def create_budget_reservation(
+    db: AsyncSession,
+    *,
+    cost_center_id: int | None,
+    estimated_cost: float | None,
+    current_user: User | None,
+    source_type: str,
+    source_id: int | None,
+    print_archive_id: int | None = None,
+    exclude_queue_item_id: int | None = None,
+) -> BudgetReservation | None:
+    if not await is_billing_enabled(db):
+        return None
+
+    if cost_center_id is None:
+        raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
+
+    await validate_print_budget(
+        db,
+        cost_center_id=cost_center_id,
+        estimated_cost=estimated_cost,
+        current_user=current_user,
+        exclude_queue_item_id=exclude_queue_item_id,
+        exclude_reservation_source_type=source_type,
+        exclude_reservation_source_id=source_id,
+    )
+
+    existing = None
+    if source_id is not None:
+        existing = await db.scalar(
+            select(BudgetReservation).where(
+                BudgetReservation.status == "active",
+                BudgetReservation.source_type == source_type,
+                BudgetReservation.source_id == source_id,
+            )
+        )
+    if existing is not None:
+        existing.cost_center_id = cost_center_id
+        existing.amount = float(estimated_cost or 0.0)
+        if print_archive_id is not None:
+            existing.print_archive_id = print_archive_id
+        await db.flush()
+        return existing
+
+    reservation = BudgetReservation(
+        cost_center_id=cost_center_id,
+        amount=float(estimated_cost or 0.0),
+        status="active",
+        source_type=source_type,
+        source_id=source_id,
+        print_archive_id=print_archive_id,
+    )
+    db.add(reservation)
+    await db.flush()
+    return reservation
+
+
+async def release_budget_reservation(
+    db: AsyncSession,
+    *,
+    source_type: str | None = None,
+    source_id: int | None = None,
+    print_archive_id: int | None = None,
+    status: str = "released",
+) -> int:
+    conditions = [BudgetReservation.status == "active"]
+    if print_archive_id is not None:
+        conditions.append(BudgetReservation.print_archive_id == print_archive_id)
+    else:
+        conditions.extend(
+            [
+                BudgetReservation.source_type == source_type,
+                BudgetReservation.source_id == source_id,
+            ]
+        )
+
+    result = await db.execute(select(BudgetReservation).where(*conditions))
+    reservations = result.scalars().all()
+    for reservation in reservations:
+        reservation.status = status
+        reservation.released_at = datetime.now(timezone.utc)
+    if reservations:
+        await db.flush()
+    return len(reservations)

+ 75 - 0
backend/app/services/finance_defaults.py

@@ -0,0 +1,75 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
+from backend.app.models.settings import Settings as AppSettingModel
+from backend.app.models.user import User
+from backend.app.schemas.settings import AppSettings as AppSettingsSchema
+
+
+async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
+    """Ensure wallet and private cost center defaults exist for a user.
+
+    Returns True when database objects were created or changed.
+    """
+    changed = False
+
+    wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
+    if wallet is None:
+        # Respect admin-configured currency if present, otherwise fall back to app default
+        default_currency = AppSettingsSchema().currency
+        result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
+        setting = result.scalar_one_or_none()
+        currency = setting.value if setting and setting.value else default_currency
+        db.add(UserWallet(user_id=user.id, balance=0.0, currency=currency))
+        changed = True
+
+    private_center = (
+        (
+            await db.execute(
+                select(CostCenter)
+                .where(
+                    CostCenter.is_private.is_(True),
+                    CostCenter.owner_user_id == user.id,
+                )
+                .order_by(CostCenter.id.asc())
+            )
+        )
+        .scalars()
+        .first()
+    )
+
+    if private_center is None:
+        private_center = CostCenter(
+            name=user.username,
+            is_active=True,
+            is_private=True,
+            owner_user_id=user.id,
+        )
+        db.add(private_center)
+        await db.flush()
+        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(
+            select(CostCenterMember).where(
+                CostCenterMember.cost_center_id == private_center.id,
+                CostCenterMember.user_id == user.id,
+            )
+        )
+    ).scalar_one_or_none()
+    if membership is None:
+        db.add(CostCenterMember(cost_center_id=private_center.id, user_id=user.id, can_print=True))
+        changed = True
+
+    return changed

+ 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)

+ 165 - 0
backend/app/services/print_cost_estimate.py

@@ -0,0 +1,165 @@
+"""Trusted server-side cost estimates for queued prints."""
+
+import json
+import logging
+from pathlib import Path
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.config import settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.utils import threemf_tools
+from backend.app.utils.safe_path import safe_join_under
+
+logger = logging.getLogger(__name__)
+
+
+def plate_scoped_run_estimate(
+    archive: PrintArchive,
+    full_path: Path | None,
+    plate_id: int | None = None,
+) -> tuple[float | None, float | None]:
+    """Return trusted ``(grams, cost)`` for one run of an archive plate."""
+
+    whole_grams = archive.filament_used_grams
+    selected_plate = archive.plate_id if plate_id is None else plate_id
+    if selected_plate is None or full_path is None or not full_path.exists():
+        return whole_grams, archive.cost
+    try:
+        plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
+    except Exception as exc:
+        logger.debug(
+            "Plate-scoped estimate failed for archive %s (plate %s): %s",
+            archive.id,
+            selected_plate,
+            exc,
+        )
+        return whole_grams, archive.cost
+    if not plate_grams or plate_grams <= 0:
+        return whole_grams, archive.cost
+    plate_cost = archive.cost
+    if archive.cost and whole_grams and whole_grams > 0:
+        plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
+    return round(plate_grams, 2), plate_cost
+
+
+def _source_path(library_file: LibraryFile) -> Path:
+    path = Path(library_file.file_path)
+    if path.is_absolute():
+        # SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
+        # configured external libraries; this branch performs no path join.
+        return path
+    return safe_join_under(settings.base_dir, library_file.file_path, http=False)
+
+
+def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
+    if isinstance(mapping, list):
+        return mapping
+    if isinstance(mapping, str):
+        try:
+            parsed = json.loads(mapping)
+        except (TypeError, json.JSONDecodeError):
+            return None
+        return parsed if isinstance(parsed, list) else None
+    return None
+
+
+def _global_tray_id(assignment: SpoolAssignment) -> int:
+    if assignment.ams_id == 255:
+        return 254 + assignment.tray_id
+    if assignment.ams_id >= 128:
+        return assignment.ams_id
+    return assignment.ams_id * 4 + assignment.tray_id
+
+
+async def _default_cost_per_kg(db: AsyncSession) -> float:
+    from backend.app.api.routes.settings import get_setting
+
+    raw = await get_setting(db, "default_filament_cost")
+    try:
+        return float(raw) if raw is not None else 25.0
+    except (TypeError, ValueError):
+        return 25.0
+
+
+async def estimate_queue_source_cost(
+    db: AsyncSession,
+    *,
+    archive: PrintArchive | None = None,
+    library_file: LibraryFile | None = None,
+    plate_id: int | None = None,
+    ams_mapping: list[int] | str | None = None,
+    printer_id: int | None = None,
+) -> float | None:
+    """Compute a queue cost without trusting the request's display hint."""
+
+    if archive is not None:
+        archive_path = settings.base_dir / archive.file_path
+        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
+
+    path = _source_path(library_file)
+    usage: list[dict] = []
+    if path.exists():
+        usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
+
+    metadata = library_file.file_metadata or {}
+    if not usage:
+        try:
+            grams = float(metadata.get("filament_used_grams") or 0)
+        except (TypeError, ValueError):
+            grams = 0
+        if grams > 0:
+            usage = [{"slot_id": 1, "used_g": grams}]
+
+    if not usage:
+        return None
+
+    default_cost = await _default_cost_per_kg(db)
+    cost_by_tray: dict[int, float | None] = {}
+    mapping = _parse_mapping(ams_mapping)
+    if printer_id is not None and mapping:
+        assignments = (
+            (
+                await db.execute(
+                    select(SpoolAssignment)
+                    .options(selectinload(SpoolAssignment.spool))
+                    .where(SpoolAssignment.printer_id == printer_id)
+                )
+            )
+            .scalars()
+            .all()
+        )
+        cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
+
+    total = 0.0
+    for filament in usage:
+        try:
+            slot_id = int(filament.get("slot_id") or 0)
+            grams = float(filament.get("used_g") or 0)
+        except (TypeError, ValueError):
+            continue
+        tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
+        cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
+        if cost_per_kg is None or cost_per_kg <= 0:
+            cost_per_kg = default_cost
+        total += (grams / 1000.0) * cost_per_kg
+
+    return round(total, 2) if total > 0 else None

+ 124 - 1
backend/app/services/print_scheduler.py

@@ -4,10 +4,12 @@ import asyncio
 import json
 import logging
 import time
+import uuid
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from pathlib import Path
 
+from fastapi import HTTPException
 from sqlalchemy import func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -35,8 +37,14 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
+from backend.app.services.finance_budget import (
+    create_budget_reservation,
+    release_budget_reservation,
+    validate_print_budget,
+)
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.notification_service import notification_service
+from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.services.printer_manager import (
     printer_manager,
     supports_airduct,
@@ -521,6 +529,11 @@ class PrintScheduler:
         # sequential caller, callbacks on the same loop, so no lock.
         # item_id -> (printer_id, remote_filename, archive_id)
         self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
+        # Budget reservations created for a dispatch whose print command has
+        # not been confirmed yet. `_dispatch_one` releases these on every
+        # unsuccessful exit; a successful start removes the item id and leaves
+        # the reservation for finance_billing to consume with the archive.
+        self._unconfirmed_budget_reservations: set[int] = set()
 
     async def run(self):
         """Main loop - check queue every interval."""
@@ -1344,6 +1357,11 @@ class PrintScheduler:
                 # A confirmed send removes the entry itself, so this is a no-op
                 # on the happy path.
                 self._rollback_unconfirmed_expected_print(item_id)
+                # Mirror the pre-#1625 background-dispatch lifecycle: a
+                # 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_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
@@ -1375,6 +1393,38 @@ class PrintScheduler:
                 exc_info=True,
             )
 
+    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):
+            async with async_session() as cleanup_db:
+                try:
+                    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
+                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.
 
@@ -3835,6 +3885,57 @@ class PrintScheduler:
         """
         logger.info("Starting queue item %s", item.id)
 
+        # Also covers a reservation left active by a process interruption
+        # during an earlier attempt. `_dispatch_one` releases this marker on
+        # every exit unless start_print() confirms that the command was sent.
+        self._unconfirmed_budget_reservations.add(item.id)
+        try:
+            from backend.app.models.user import User
+
+            queue_user = await db.get(User, item.created_by_id) if item.created_by_id is not None else None
+            # Recompute at the final authorization boundary as well as enqueue
+            # time. This covers rows created before the server-side estimate
+            # migration and prevents any alternate write path from weakening
+            # the budget reservation.
+            archive = await db.get(PrintArchive, item.archive_id) if item.archive_id is not None else None
+            library_file = await db.get(LibraryFile, item.library_file_id) if item.library_file_id is not None else None
+            item.estimated_cost = await estimate_queue_source_cost(
+                db,
+                archive=archive,
+                library_file=library_file,
+                plate_id=item.plate_id,
+                ams_mapping=item.ams_mapping,
+                printer_id=item.printer_id,
+            )
+            await validate_print_budget(
+                db,
+                cost_center_id=item.cost_center_id,
+                estimated_cost=item.estimated_cost,
+                current_user=queue_user,
+                exclude_queue_item_id=item.id,
+                exclude_reservation_source_type="print_queue",
+                exclude_reservation_source_id=item.id,
+            )
+            budget_reservation = await create_budget_reservation(
+                db,
+                cost_center_id=item.cost_center_id,
+                estimated_cost=item.estimated_cost,
+                current_user=queue_user,
+                source_type="print_queue",
+                source_id=item.id,
+                print_archive_id=item.archive_id,
+                exclude_queue_item_id=item.id,
+            )
+            await db.commit()
+        except HTTPException as exc:
+            item.status = "failed"
+            item.error_message = str(exc.detail)
+            item.completed_at = datetime.now(timezone.utc)
+            await db.commit()
+            logger.error("Queue item %s: Budget check failed: %s", item.id, item.error_message)
+            await self._power_off_if_needed(db, item)
+            return
+
         # Get printer first (needed for both paths)
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         printer = result.scalar_one_or_none()
@@ -3955,11 +4056,14 @@ class PrintScheduler:
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     project_id=item.project_id,
+                    cost_center_id=item.cost_center_id,
                     library_file_id=item.library_file_id,  # per-file project progress (#1897)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 if archive:
                     item.archive_id = archive.id
+                    if budget_reservation is not None:
+                        budget_reservation.print_archive_id = archive.id
                     if item.cleanup_library_after_dispatch and not library_file.is_external:
                         item.library_file_id = None
                         cleanup_disk_paths.append(file_path)
@@ -4275,6 +4379,7 @@ class PrintScheduler:
                 archive.id,
                 ams_mapping=ams_mapping,
                 created_by_id=item.created_by_id,
+                cost_center_id=item.cost_center_id,
                 plate_id=item.plate_id,
             )
             # Registration happens before the print command by necessity (the
@@ -4306,11 +4411,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:
@@ -4347,6 +4453,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:
@@ -4411,6 +4527,7 @@ class PrintScheduler:
             # survive. Anything still in this dict when _dispatch_one exits gets
             # rolled back.
             self._unconfirmed_expected_print.pop(item.id, None)
+            self._unconfirmed_budget_reservations.discard(item.id)
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # status='processing' from upload start until the printer acked
@@ -4782,6 +4899,12 @@ class PrintScheduler:
                         f"prompt or error, confirm its SD card is readable, and start the job again."
                     )
                 item.completed_at = datetime.now(timezone.utc)
+                await release_budget_reservation(
+                    db,
+                    source_type="print_queue",
+                    source_id=item.id,
+                    status="released",
+                )
                 await db.commit()
                 return "gave_up"
             item.status = "pending"

+ 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,

+ 4 - 0
backend/tests/integration/test_auth_apikey_rbac.py

@@ -134,6 +134,10 @@ class TestApiKeyDenylistIntegrity:
             Permission.API_KEYS_CREATE,
             Permission.API_KEYS_UPDATE,
             Permission.API_KEYS_DELETE,
+            Permission.COST_CENTERS_READ_OWN,
+            Permission.COST_CENTERS_READ_ALL,
+            Permission.COST_CENTERS_MODIFY,
+            Permission.COST_CENTERS_CREATE,
             Permission.GITHUB_BACKUP,
             Permission.GITHUB_RESTORE,
             Permission.FIRMWARE_UPDATE,

+ 1193 - 0
backend/tests/integration/test_finance_api.py

@@ -0,0 +1,1193 @@
+"""Integration tests for the finance/billing API."""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.auth import get_password_hash
+from backend.app.core.database import repair_wallet_ledger_internal
+from backend.app.models.archive import PrintArchive
+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
+
+
+class TestFinanceAPI:
+    @pytest.fixture
+    async def admin_user(self, db_session):
+        user = User(
+            username="finance-admin",
+            email="finance-admin@example.com",
+            password_hash=get_password_hash("AdminPass1!"),
+            role="admin",
+            is_active=True,
+        )
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+        return user
+
+    @pytest.fixture
+    async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
+        db_session.add(Settings(key="auth_enabled", value="true"))
+        db_session.add(Settings(key="advanced_auth_enabled", value="false"))
+        # Ensure billing is enabled for finance integration tests
+        existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
+        if existing is None:
+            db_session.add(Settings(key="billing_enabled", value="true"))
+        else:
+            existing.value = "true"
+        await db_session.commit()
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": admin_user.username, "password": "AdminPass1!"},
+        )
+        assert response.status_code == 200
+        return {"Authorization": f"Bearer {response.json()['access_token']}"}
+
+    async def _enable_basic_user_creation(self, db_session):
+        return None
+
+    async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
+        response = await async_client.post(
+            "/api/v1/users",
+            json={
+                "username": username,
+                "password": "Regularpass1!",
+                "email": f"{username}@example.com",
+                "role": "user",
+            },
+            headers=auth_headers,
+        )
+        assert response.status_code == 201
+        return response.json()
+
+    async def _login_user(self, async_client: AsyncClient, username: str) -> dict[str, str]:
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": username, "password": "Regularpass1!"},
+        )
+        assert response.status_code == 200
+        return {"Authorization": f"Bearer {response.json()['access_token']}"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_balance_get_does_not_create_wallet(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        target = User(
+            username="balance-without-wallet",
+            email="balance-without-wallet@example.com",
+            password_hash=get_password_hash("Regularpass1!"),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(target)
+        await db_session.commit()
+        await db_session.refresh(target)
+        assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == target.id)) is None
+
+        response = await async_client.get(f"/api/v1/finance/users/{target.id}/balance", headers=auth_headers)
+
+        assert response.status_code == 200
+        assert response.json()["balance"] == 0
+        assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == target.id)) is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wallet_ledger_rebuild_processes_more_than_one_batch(
+        self,
+        db_session,
+        admin_user: User,
+    ):
+        wallet = UserWallet(user_id=admin_user.id, balance=0)
+        db_session.add(wallet)
+        await db_session.flush()
+        await db_session.execute(
+            WalletTransaction.__table__.insert(),
+            [{"user_id": admin_user.id, "transaction_type": "deposit", "amount": 0.01} for _ in range(1001)],
+        )
+
+        await repair_wallet_ledger_internal(db_session)
+        await db_session.refresh(wallet)
+        last_transaction = await db_session.scalar(
+            select(WalletTransaction)
+            .where(WalletTransaction.user_id == admin_user.id)
+            .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+            .limit(1)
+        )
+
+        assert wallet.balance == 10.01
+        assert last_transaction is not None
+        assert last_transaction.balance_after == 10.01
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_cost_center_assign_member_and_list_mine(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        await self._enable_basic_user_creation(db_session)
+        created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
+        user_headers = await self._login_user(async_client, "carol")
+
+        negative_budget_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={"name": "Invalid Budget", "total_budget": -1},
+            headers=auth_headers,
+        )
+        assert negative_budget_response.status_code == 422
+
+        create_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={
+                "name": "Shared Lab",
+                "monthly_budget": 120.0,
+                "total_budget": 999.0,
+                "is_active": True,
+            },
+            headers=auth_headers,
+        )
+
+        assert create_response.status_code == 200
+        shared_center = create_response.json()
+        assert shared_center["name"] == "Shared Lab"
+        assert shared_center["monthly_budget"] == 120.0
+        assert shared_center["total_budget"] is None
+        assert shared_center["budget_mode"] == "monthly"
+
+        member_response = await async_client.post(
+            f"/api/v1/finance/cost-centers/{shared_center['id']}/members",
+            json={"user_id": created_user["id"], "can_print": False},
+            headers=auth_headers,
+        )
+
+        assert member_response.status_code == 200
+        assert member_response.json()["user_id"] == created_user["id"]
+        assert member_response.json()["can_print"] is False
+
+        mine_response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
+        assert mine_response.status_code == 200
+        mine_names = {center["name"] for center in mine_response.json()}
+        assert "carol" in mine_names
+        assert "Shared Lab" in mine_names
+
+        detail_response = await async_client.get(
+            f"/api/v1/finance/cost-centers/{shared_center['id']}", headers=auth_headers
+        )
+        assert detail_response.status_code == 200
+        detail = detail_response.json()
+        assert len(detail["members"]) == 1
+        assert detail["members"][0]["user_id"] == created_user["id"]
+
+        remove_response = await async_client.delete(
+            f"/api/v1/finance/cost-centers/{shared_center['id']}/members/{created_user['id']}",
+            headers=auth_headers,
+        )
+        assert remove_response.status_code == 200
+
+        mine_after_remove = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
+        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
+
+        rename_response = await async_client.patch(
+            f"/api/v1/finance/cost-centers/{private_center.id}",
+            json={"name": "Renamed private center"},
+            headers=auth_headers,
+        )
+        assert rename_response.status_code == 400
+        await db_session.refresh(private_center)
+        assert private_center.name == "private-budget-user"
+
+        negative_budget_response = await async_client.patch(
+            f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
+            json={"monthly_budget": -0.01},
+            headers=auth_headers,
+        )
+        assert negative_budget_response.status_code == 422
+
+        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(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        admin_user,
+        db_session,
+    ):
+        """A user's private cost center and unassigned entries share one wallet."""
+        await self._enable_basic_user_creation(db_session)
+        created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
+
+        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
+
+        # The owner's private cost center affects both its own ledger and the wallet.
+        deposit = await async_client.post(
+            f"/api/v1/finance/users/{created_user['id']}/deposit",
+            json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": private_center.id},
+            headers=auth_headers,
+        )
+        assert deposit.status_code == 200
+        assert deposit.json()["transaction"]["cost_center_id"] == private_center.id
+        assert deposit.json()["transaction"]["balance_after"] == 25.0  # CC balance
+        assert deposit.json()["balance"]["balance"] == 25.0  # Response shows CC balance
+
+        # A withdrawal updates both views by the same amount.
+        withdraw = await async_client.post(
+            f"/api/v1/finance/users/{created_user['id']}/withdraw",
+            json={"amount": 5.0, "description": "CC Usage", "cost_center_id": private_center.id},
+            headers=auth_headers,
+        )
+        assert withdraw.status_code == 200
+        assert withdraw.json()["transaction"]["amount"] == -5.0
+        assert withdraw.json()["transaction"]["balance_after"] == 20.0  # CC balance after withdraw
+        assert withdraw.json()["balance"]["balance"] == 20.0  # Response shows CC balance
+
+        # Personal deposit: affects user wallet
+        personal_deposit = await async_client.post(
+            f"/api/v1/finance/users/{created_user['id']}/deposit",
+            json={"amount": 30.0, "description": "Personal top-up", "cost_center_id": None},
+            headers=auth_headers,
+        )
+        assert personal_deposit.status_code == 200
+        assert personal_deposit.json()["transaction"]["cost_center_id"] is None
+        assert personal_deposit.json()["transaction"]["balance_after"] == 50.0
+        assert personal_deposit.json()["balance"]["balance"] == 50.0
+
+        transactions_response = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
+        )
+        assert transactions_response.status_code == 200
+        transactions = transactions_response.json()
+        assert len(transactions) == 3
+        cc_txs = [tx for tx in transactions if tx["cost_center_id"] == private_center.id]
+        personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
+        assert len(cc_txs) == 2
+        assert len(personal_txs) == 1
+
+        balance_response = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
+        )
+        assert balance_response.status_code == 200
+        assert balance_response.json()["balance"] == 50.0
+
+        # Delete personal transaction, user wallet should decrease
+        personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
+        delete_response = await async_client.delete(
+            f"/api/v1/finance/transactions/{personal_tx['id']}", headers=auth_headers
+        )
+        assert delete_response.status_code == 200
+
+        balance_after_delete = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
+        )
+        assert balance_after_delete.status_code == 200
+        assert balance_after_delete.json()["balance"] == 20.0
+
+        remaining = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
+        )
+        assert remaining.status_code == 200
+        assert len(remaining.json()) == 2  # 2 CC transactions remain
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_and_user_balances_agree_after_charge_adjustment_and_delete(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        created_user = await self._create_user_via_api(async_client, auth_headers, "balance-lifecycle")
+        user = await db_session.get(User, created_user["id"])
+        operators = await db_session.scalar(select(Group).where(Group.name == "Operators"))
+        assert operators is not None
+        user.groups.append(operators)
+        await db_session.commit()
+        user_headers = await self._login_user(async_client, "balance-lifecycle")
+        private_center = await db_session.scalar(
+            select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
+        )
+        assert private_center is not None
+
+        deposit = await async_client.post(
+            f"/api/v1/finance/users/{user.id}/deposit",
+            json={"amount": 20.0, "cost_center_id": private_center.id},
+            headers=auth_headers,
+        )
+        assert deposit.status_code == 200
+
+        archive = PrintArchive(
+            filename="charged.gcode.3mf",
+            file_path="archives/test/charged.gcode.3mf",
+            file_size=10,
+            status="completed",
+            cost=4.0,
+            created_by_id=user.id,
+            cost_center_id=private_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        assert await apply_print_charge_for_archive(db_session, archive.id) is True
+        await db_session.commit()
+
+        adjustment = await async_client.post(
+            f"/api/v1/finance/users/{user.id}/deposit",
+            json={"amount": 5.0, "description": "temporary adjustment"},
+            headers=auth_headers,
+        )
+        assert adjustment.status_code == 200
+
+        async def assert_views_agree(expected: float):
+            admin_view = await async_client.get(
+                f"/api/v1/finance/users/{user.id}/balance",
+                headers=auth_headers,
+            )
+            user_view = await async_client.get("/api/v1/finance/me/balance", headers=user_headers)
+            assert admin_view.status_code == 200
+            assert user_view.status_code == 200
+            assert admin_view.json()["balance"] == expected
+            assert user_view.json()["balance"] == expected
+
+        await assert_views_agree(21.0)
+
+        delete_response = await async_client.delete(
+            f"/api/v1/finance/transactions/{adjustment.json()['transaction']['id']}",
+            headers=auth_headers,
+        )
+        assert delete_response.status_code == 200
+        await assert_views_agree(16.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        await self._enable_basic_user_creation(db_session)
+        created_user = await self._create_user_via_api(async_client, auth_headers, "erin")
+
+        shared_center = await db_session.scalar(
+            select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
+        )
+        assert shared_center is not None
+
+        first_deposit = await async_client.post(
+            f"/api/v1/finance/users/{created_user['id']}/deposit",
+            json={"amount": 25.0, "description": "CC top-up", "cost_center_id": shared_center.id},
+            headers=auth_headers,
+        )
+        assert first_deposit.status_code == 200
+
+        cc_withdraw = await async_client.post(
+            f"/api/v1/finance/users/{created_user['id']}/withdraw",
+            json={"amount": 5.0, "description": "CC usage", "cost_center_id": shared_center.id},
+            headers=auth_headers,
+        )
+        assert cc_withdraw.status_code == 200
+
+        personal_deposit = await async_client.post(
+            f"/api/v1/finance/users/{created_user['id']}/deposit",
+            json={"amount": 12.0, "description": "Personal top-up", "cost_center_id": None},
+            headers=auth_headers,
+        )
+        assert personal_deposit.status_code == 200
+
+        delete_response = await async_client.delete(
+            f"/api/v1/finance/transactions/{first_deposit.json()['transaction']['id']}",
+            headers=auth_headers,
+        )
+        assert delete_response.status_code == 200
+
+        transactions_response = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
+        )
+        assert transactions_response.status_code == 200
+        transactions = transactions_response.json()
+        assert len(transactions) == 2
+
+        cc_transaction = next(tx for tx in transactions if tx["cost_center_id"] == shared_center.id)
+        assert cc_transaction["amount"] == -5.0
+        assert cc_transaction["balance_after"] == -5.0
+
+        balance_response = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
+        )
+        assert balance_response.status_code == 200
+        assert balance_response.json()["balance"] == 7.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_print_charge_stays_deleted_after_recalculate(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        await self._enable_basic_user_creation(db_session)
+        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,
+            filename="print.gcode",
+            file_path="archives/test/print.gcode",
+            file_size=10,
+            content_hash="hash-print",
+            status="completed",
+            cost=4.0,
+            created_by_id=user.id,
+        )
+        db_session.add(archive)
+        await db_session.flush()
+
+        tx = WalletTransaction(
+            user_id=user.id,
+            transaction_type="print_charge",
+            amount=-4.0,
+            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)))
+            .scalars()
+            .all()
+        )
+        assert len(tx_rows_before) == 1
+
+        delete_response = await async_client.delete(
+            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)))
+            .scalars()
+            .all()
+        )
+        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,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Test that editing a transaction (user, cost_center, amount, description) rebuilds ledger."""
+        await self._enable_basic_user_creation(db_session)
+        user1 = await self._create_user_via_api(async_client, auth_headers, "user1")
+        user2 = await self._create_user_via_api(async_client, auth_headers, "user2")
+
+        # Create a cost center
+        cc_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={"name": "Test Center", "is_active": True},
+            headers=auth_headers,
+        )
+        assert cc_response.status_code == 200
+        cost_center = cc_response.json()
+
+        # Get user records from DB
+        user1_db = await db_session.scalar(select(User).where(User.id == user1["id"]))
+        user2_db = await db_session.scalar(select(User).where(User.id == user2["id"]))
+
+        # Create a personal transaction for user1
+        tx_response = await async_client.post(
+            f"/api/v1/finance/users/{user1_db.id}/deposit",
+            json={"amount": 50.0, "description": "Initial deposit"},
+            headers=auth_headers,
+        )
+        assert tx_response.status_code == 200
+        tx_data = tx_response.json()
+        tx_id = tx_data["transaction"]["id"]
+
+        # Get the original transaction
+        original_tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.id == tx_id))
+        assert original_tx.user_id == user1_db.id
+        assert original_tx.cost_center_id is None
+        assert original_tx.amount == 50.0
+        assert original_tx.balance_after == 50.0
+
+        # Edit the transaction: change user, add cost center, change amount
+        edit_response = await async_client.patch(
+            f"/api/v1/finance/transactions/{tx_id}",
+            json={
+                "user_id": user2_db.id,
+                "cost_center_id": cost_center["id"],
+                "amount": 75.0,
+                "description": "Updated deposit (Admin edit)",
+            },
+            headers=auth_headers,
+        )
+        assert edit_response.status_code == 200
+        edited_tx_data = edit_response.json()
+
+        # Verify transaction was updated
+        assert edited_tx_data["user_id"] == user2_db.id
+        assert edited_tx_data["cost_center_id"] == cost_center["id"]
+        assert edited_tx_data["amount"] == 75.0
+        # 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(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Posting a manual print (manual_adjustment) creates a transaction and rebuilds ledger."""
+        await self._enable_basic_user_creation(db_session)
+        created_user = await self._create_user_via_api(async_client, auth_headers, "gina")
+
+        # Get user DB record
+        user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
+        assert user is not None
+
+        # Private cost center for user
+        private_cc = await db_session.scalar(
+            select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
+        )
+        assert private_cc is not None
+
+        # Post manual print affecting the cost center
+        payload = {
+            "user_id": user.id,
+            "cost_center_id": private_cc.id,
+            "amount": 4.0,
+            "description": "Manual adjustment for a print",
+            "created_at": "2026-05-12T12:00:00Z",
+        }
+
+        response = await async_client.post("/api/v1/finance/transactions/manual", json=payload, headers=auth_headers)
+        assert response.status_code == 200
+        resp_json = response.json()
+        assert "transaction" in resp_json or "id" in resp_json
+
+        # Response contains the created transaction details
+        assert resp_json["transaction_type"] == "manual_adjustment"
+        assert resp_json["amount"] == -4.0
+        assert resp_json["cost_center_id"] == private_cc.id
+
+        # The response includes the computed running balance for the transaction
+        assert resp_json.get("balance_after") == -4.0
+
+        negative_amount_response = await async_client.post(
+            "/api/v1/finance/transactions/manual",
+            json={**payload, "amount": -1},
+            headers=auth_headers,
+        )
+        assert negative_amount_response.status_code == 422
+
+        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."""
+
+    @pytest.fixture
+    async def admin_user(self, db_session):
+        user = User(
+            username="partial-admin",
+            email="partial-admin@example.com",
+            password_hash=get_password_hash("AdminPass1!"),
+            role="admin",
+            is_active=True,
+        )
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+        return user
+
+    @pytest.fixture
+    async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
+        db_session.add(Settings(key="auth_enabled", value="true"))
+        db_session.add(Settings(key="advanced_auth_enabled", value="false"))
+        # Ensure billing is enabled for these partial-charge integration tests
+        existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
+        if existing is None:
+            db_session.add(Settings(key="billing_enabled", value="true"))
+        else:
+            existing.value = "true"
+        await db_session.commit()
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": admin_user.username, "password": "AdminPass1!"},
+        )
+        assert response.status_code == 200
+        return {"Authorization": f"Bearer {response.json()['access_token']}"}
+
+    async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
+        response = await async_client.post(
+            "/api/v1/users",
+            json={
+                "username": username,
+                "password": "Regularpass1!",
+                "email": f"{username}@example.com",
+                "role": "user",
+            },
+            headers=auth_headers,
+        )
+        assert response.status_code == 201
+        return response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_aborted_print_charges_proportionally_via_recalculate_endpoint(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Verify aborted prints are included in recalculate and charged proportionally."""
+        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
+
+        # Wallet is already created by ensure_user_finance_defaults during user creation
+
+        # Archive: completed print (100% charge)
+        completed = PrintArchive(
+            printer_id=None,
+            filename="completed.3mf",
+            file_path="archives/test/completed.3mf",
+            file_size=100,
+            content_hash="partial-complete",
+            status="completed",
+            cost=10.0,
+            created_by_id=user.id,
+        )
+
+        # Archive: aborted print (50% filament used = 50% charge)
+        aborted = PrintArchive(
+            printer_id=None,
+            filename="aborted.3mf",
+            file_path="archives/test/aborted.3mf",
+            file_size=100,
+            content_hash="partial-aborted",
+            status="aborted",
+            cost=8.0,
+            filament_used_grams=50.0,
+            extra_data={"filament_grams_total": 100.0},
+            created_by_id=user.id,
+        )
+
+        # Archive: failed print (0% filament used = no charge)
+        failed = PrintArchive(
+            printer_id=None,
+            filename="failed.3mf",
+            file_path="archives/test/failed.3mf",
+            file_size=100,
+            content_hash="partial-failed",
+            status="failed",
+            cost=5.0,
+            filament_used_grams=0.0,
+            created_by_id=user.id,
+        )
+
+        db_session.add_all([completed, aborted, failed])
+        await db_session.commit()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_charges_appear_in_transaction_ledger(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Verify transaction descriptions indicate partial charges."""
+        created_user = await self._create_user_via_api(async_client, auth_headers, "grace")
+        user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
+        assert user is not None
+
+        # Wallet is already created by ensure_user_finance_defaults during user creation
+
+        cancelled = PrintArchive(
+            printer_id=None,
+            filename="cancelled.3mf",
+            file_path="archives/test/cancelled.3mf",
+            file_size=100,
+            content_hash="partial-cancel",
+            status="cancelled",
+            cost=12.0,
+            filament_used_grams=25.0,
+            extra_data={"filament_grams_total": 100.0},
+            print_name="Partially Cancelled Print",
+            created_by_id=user.id,
+        )
+        db_session.add(cancelled)
+        await db_session.commit()
+
+        from backend.app.services.finance_billing import apply_print_charge_for_archive
+
+        changed = await apply_print_charge_for_archive(db_session, cancelled.id)
+        assert changed is True
+        await db_session.commit()
+
+        tx_response = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
+        )
+        assert tx_response.status_code == 200
+        transactions = tx_response.json()
+        assert len(transactions) == 1
+
+        tx = transactions[0]
+        assert tx["transaction_type"] == "print_charge"
+        assert tx["amount"] == -3.0  # 25% of 12.0
+        assert "cancelled" in tx["description"].lower()
+        assert "25.0g/100.0" in tx["description"]  # filament amounts in description
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_charges_with_cost_center_override(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Verify partial charges respect cost_center_id when present."""
+        created_user = await self._create_user_via_api(async_client, auth_headers, "henry")
+        user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
+        assert user is not None
+
+        # Create cost centers
+        default_cc = CostCenter(name="Default CC", owner_user_id=user.id, is_active=True, is_private=False)
+        lab_cc = CostCenter(name="Lab CC", owner_user_id=user.id, is_active=True, is_private=False)
+        db_session.add_all([default_cc, lab_cc])
+        await db_session.flush()
+
+        # Wallet is already created by ensure_user_finance_defaults during user creation
+
+        # Archive assigned to default_cc
+        aborted = PrintArchive(
+            printer_id=None,
+            filename="aborted_cc.3mf",
+            file_path="archives/test/aborted_cc.3mf",
+            file_size=100,
+            content_hash="partial-cc",
+            status="aborted",
+            cost=6.0,
+            filament_used_grams=30.0,
+            extra_data={"filament_grams_total": 100.0},
+            cost_center_id=default_cc.id,
+            created_by_id=user.id,
+        )
+        db_session.add(aborted)
+        await db_session.commit()
+
+        # Manually apply charge with override
+        from backend.app.services.finance_billing import apply_print_charge_for_archive
+
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            aborted.id,
+            cost_center_id=lab_cc.id,
+        )
+        await db_session.commit()
+
+        assert changed is True
+
+        tx_response = await async_client.get(
+            f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
+        )
+        assert tx_response.status_code == 200
+        transactions = tx_response.json()
+        assert len(transactions) == 1
+
+        tx = transactions[0]
+        assert tx["cost_center_id"] == lab_cc.id  # Overridden to lab_cc
+        assert tx["amount"] == pytest.approx(-1.8, abs=0.01)  # 30% of 6.0
+
+
+class TestFinanceUserDefaults:
+    """Tests for user creation and finance defaults initialization."""
+
+    @pytest.fixture
+    async def admin_user(self, db_session):
+        user = User(
+            username="billing-admin",
+            email="billing-admin@example.com",
+            password_hash=get_password_hash("AdminPass1!"),
+            role="admin",
+            is_active=True,
+        )
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+        return user
+
+    @pytest.fixture
+    async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
+        db_session.add(Settings(key="auth_enabled", value="true"))
+        db_session.add(Settings(key="advanced_auth_enabled", value="false"))
+        await db_session.commit()
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": admin_user.username, "password": "AdminPass1!"},
+        )
+        assert response.status_code == 200
+        return {"Authorization": f"Bearer {response.json()['access_token']}"}
+
+    async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
+        response = await async_client.post(
+            "/api/v1/users",
+            json={
+                "username": username,
+                "password": "Regularpass1!",
+                "email": f"{username}@example.com",
+                "role": "user",
+            },
+            headers=auth_headers,
+        )
+        assert response.status_code == 201
+        return response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_initializes_wallet_and_private_cost_center(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Verify user creation initializes wallet, private cost center, and membership."""
+        result = await async_client.post(
+            "/api/v1/users",
+            json={
+                "username": "alice",
+                "password": "Regularpass1!",
+                "email": "alice@example.com",
+                "role": "user",
+            },
+            headers=auth_headers,
+        )
+
+        assert result.status_code == 201
+        created = result.json()
+        assert created["username"] == "alice"
+
+        user = await db_session.scalar(select(User).where(User.username == "alice"))
+        assert user is not None
+
+        from backend.app.models.finance import CostCenterMember
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+
+        private_center = await db_session.scalar(
+            select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
+        )
+        assert private_center is not None
+        assert private_center.name == "alice"
+
+        membership = await db_session.scalar(
+            select(CostCenterMember).where(
+                CostCenterMember.cost_center_id == private_center.id,
+                CostCenterMember.user_id == user.id,
+            )
+        )
+        assert membership is not None
+        assert membership.can_print is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_user_keeps_private_cost_center_in_sync(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        """Verify user updates keep private cost center name in sync."""
+        created = await self._create_user_via_api(async_client, auth_headers, "bob")
+
+        response = await async_client.patch(
+            f"/api/v1/users/{created['id']}",
+            json={"username": "bobby"},
+            headers=auth_headers,
+        )
+
+        assert response.status_code == 200
+        assert response.json()["username"] == "bobby"
+
+        user = await db_session.scalar(select(User).where(User.id == created["id"]))
+        assert user is not None
+
+        private_centers = (
+            (
+                await db_session.execute(
+                    select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
+                )
+            )
+            .scalars()
+            .all()
+        )
+        assert len(private_centers) == 1
+        assert private_centers[0].name == "bobby"
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None

+ 94 - 0
backend/tests/integration/test_ldap_provision.py

@@ -20,8 +20,10 @@ from unittest.mock import patch
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.services.ldap_service import LDAPSearchResult, LDAPUserInfo
@@ -367,3 +369,95 @@ class TestLdapProvisionRoute:
         body = response.json()
         group_names = {g["name"] for g in body["groups"]}
         assert "Operators" in group_names
+
+
+class TestLdapLoginFinanceDefaults:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_successful_ldap_login_backfills_finance_defaults(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """LDAP login should ensure wallet + private cost center defaults exist.
+
+        Regression: LDAP users created before finance defaults were introduced can
+        exist without wallet/private center. A successful LDAP login must backfill
+        these defaults so billing-enabled flows have a valid personal cost center.
+        """
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "ldapadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        await _seed_ldap_settings(db_session, ldap_auto_provision="false")
+
+        legacy_user = User(
+            username="legacyldap",
+            email="legacyldap@test.com",
+            password_hash=None,
+            role="user",
+            auth_source="ldap",
+            is_active=True,
+        )
+        db_session.add(legacy_user)
+        await db_session.commit()
+        await db_session.refresh(legacy_user)
+
+        # Precondition: legacy LDAP row has no finance defaults yet.
+        wallet_before = (
+            await db_session.execute(select(UserWallet).where(UserWallet.user_id == legacy_user.id))
+        ).scalar_one_or_none()
+        private_cc_before = (
+            await db_session.execute(
+                select(CostCenter).where(
+                    CostCenter.owner_user_id == legacy_user.id,
+                    CostCenter.is_private.is_(True),
+                )
+            )
+        ).scalar_one_or_none()
+        assert wallet_before is None
+        assert private_cc_before is None
+
+        fake_ldap = LDAPUserInfo(
+            username="legacyldap",
+            email="legacyldap@test.com",
+            display_name="Legacy LDAP",
+            groups=[],
+        )
+        with patch("backend.app.services.ldap_service.authenticate_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/login",
+                json={"username": "legacyldap", "password": "irrelevant"},
+            )
+
+        assert response.status_code == 200
+        assert response.json()["user"]["auth_source"] == "ldap"
+
+        wallet_after = (
+            await db_session.execute(select(UserWallet).where(UserWallet.user_id == legacy_user.id))
+        ).scalar_one_or_none()
+        assert wallet_after is not None
+
+        private_cc_after = (
+            await db_session.execute(
+                select(CostCenter).where(
+                    CostCenter.owner_user_id == legacy_user.id,
+                    CostCenter.is_private.is_(True),
+                )
+            )
+        ).scalar_one_or_none()
+        assert private_cc_after is not None
+        assert private_cc_after.name == "legacyldap"
+
+        membership = (
+            await db_session.execute(
+                select(CostCenterMember).where(
+                    CostCenterMember.cost_center_id == private_cc_after.id,
+                    CostCenterMember.user_id == legacy_user.id,
+                )
+            )
+        ).scalar_one_or_none()
+        assert membership is not None
+        assert membership.can_print is True

+ 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."""

+ 5 - 0
backend/tests/integration/test_print_lifecycle.py

@@ -396,6 +396,10 @@ class TestTimelapseTracking:
             }
         )
 
+        # A later status update records the last non-zero progress before
+        # firmware resets it during a display-side abort.
+        client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 25}})
+
         # User cancels (goes to IDLE)
         client._process_message(
             {
@@ -409,6 +413,7 @@ class TestTimelapseTracking:
 
         assert completion_data["status"] == "aborted"
         assert "hms_errors" in completion_data
+        assert completion_data["last_progress"] == 25
 
     @pytest.mark.asyncio
     async def test_timelapse_detected_from_ipcam_data(self):

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

@@ -2,6 +2,19 @@
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.finance import CostCenter
+from backend.app.models.settings import Settings
+
+
+async def enable_billing(db_session):
+    setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
+    if setting is None:
+        db_session.add(Settings(key="billing_enabled", value="true"))
+    else:
+        setting.value = "true"
+    await db_session.commit()
 
 
 class TestPrintQueueAPI:
@@ -125,6 +138,176 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["manual_start"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_cost_center_id(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added to queue with cost_center_id."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
+        cost_center = CostCenter(name="Queue CC", is_active=True, is_private=False)
+        db_session.add(cost_center)
+        await db_session.commit()
+        await db_session.refresh(cost_center)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+                "estimated_cost": 0.01,
+            },
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["cost_center_id"] == cost_center.id
+        assert result["estimated_cost"] == 1.25
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        row = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == result["id"]))
+        assert row is not None
+        assert row.cost_center_id == cost_center.id
+        assert row.estimated_cost == 1.25
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_derives_cost_without_client_estimate(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """The persisted budget estimate comes from the archive, not the request."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
+        cost_center = CostCenter(name="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={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+            },
+        )
+
+        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(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A forged low client hint cannot bypass the server-derived budget check."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=2.0, filament_used_grams=50.0)
+        cost_center = CostCenter(name="Tiny Budget CC", is_active=True, is_private=False, monthly_budget=1.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={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+                "estimated_cost": 0.01,
+            },
+        )
+
+        assert response.status_code == 400
+        assert "exceeds available cost center budget" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_requires_cost_center_when_billing_enabled(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Billing enforcement rejects queue jobs that omit cost center."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=1.0, filament_used_grams=50.0)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+            },
+        )
+
+        assert response.status_code == 400
+        assert "Cost center is required" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_counts_pending_queue_reservations_against_budget(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Open queue items reserve budget until they leave pending/printing states."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
+        cost_center = CostCenter(name="Reserved 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)
+        await queue_item_factory(
+            printer_id=printer.id,
+            archive_id=archive.id,
+            cost_center_id=cost_center.id,
+            estimated_cost=8.0,
+            status="pending",
+        )
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+                "estimated_cost": 0.01,
+            },
+        )
+
+        assert response.status_code == 400
+        assert "exceeds available cost center budget" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_manual_start(
@@ -181,6 +364,30 @@ class TestPrintQueueAPI:
         result = response.json()
         assert result["skip_filament_check"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_cost_center_id(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Verify a pending queue item can be updated with cost_center_id."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
+        item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id)
+        cost_center = CostCenter(name="Update CC", is_active=True, is_private=False)
+        db_session.add(cost_center)
+        await db_session.commit()
+        await db_session.refresh(cost_center)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"cost_center_id": cost_center.id, "estimated_cost": 0.01},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["cost_center_id"] == cost_center.id
+        assert response.json()["estimated_cost"] == 1.25
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_project_id(
@@ -634,6 +841,54 @@ class TestPrintQueueAPI:
         assert response.status_code == 200
         assert response.json()["message"] == "Queue item deleted"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_queue_item_releases_reserved_budget(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Deleting a pending cost-center queue item releases its reserved budget."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
+        cost_center = CostCenter(
+            name="Delete Releases 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)
+        item = await queue_item_factory(
+            printer_id=printer.id,
+            archive_id=archive.id,
+            cost_center_id=cost_center.id,
+            estimated_cost=8.0,
+            status="pending",
+        )
+
+        blocked = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+                "estimated_cost": 0.01,
+            },
+        )
+        assert blocked.status_code == 400
+
+        deleted = await async_client.delete(f"/api/v1/queue/{item.id}")
+        assert deleted.status_code == 200
+
+        allowed = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "cost_center_id": cost_center.id,
+                "estimated_cost": 0.01,
+            },
+        )
+        assert allowed.status_code == 200
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
@@ -760,6 +1015,24 @@ class TestQueueStartEndpoint:
         result = response.json()
         assert result["manual_start"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_queue_item_with_cost_center_requires_estimated_cost(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Starting a cost-center queue item requires a stored estimate."""
+        await enable_billing(db_session)
+        cost_center = CostCenter(name="Start 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)
+        item = await queue_item_factory(manual_start=True, cost_center_id=cost_center.id, estimated_cost=None)
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+
+        assert response.status_code == 400
+        assert "Estimated cost is required" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_start_queue_item_not_found(self, async_client: AsyncClient):
@@ -1460,6 +1733,58 @@ class TestBulkUpdateEndpoint:
         assert response.status_code == 400
         assert "printer not found" in response.json()["detail"].lower()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_cost_center_requires_estimated_cost(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Bulk assigning a cost center requires an estimate, same as single-item updates."""
+        await enable_billing(db_session)
+        item = await queue_item_factory()
+        cost_center = CostCenter(name="Bulk 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.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id], "cost_center_id": cost_center.id},
+        )
+
+        assert response.status_code == 400
+        assert "Estimated cost is required" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_cost_center_counts_pending_reservations(
+        self, async_client: AsyncClient, queue_item_factory, archive_factory, db_session
+    ):
+        """A forged bulk-update hint cannot weaken the server-derived reservation."""
+        await enable_billing(db_session)
+        archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
+        item = await queue_item_factory(archive_id=archive.id)
+        existing = await queue_item_factory()
+        cost_center = CostCenter(name="Bulk Reserved 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)
+
+        existing.cost_center_id = cost_center.id
+        existing.estimated_cost = 8.0
+        await db_session.commit()
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 0.01},
+        )
+
+        assert response.status_code == 400
+        assert "exceeds available cost center budget" in response.json()["detail"]
+
+        await db_session.refresh(item)
+        assert item.cost_center_id is None
+        assert item.estimated_cost is None
+
 
 class TestTargetLocationFeature:
     """Tests for queue items with target_location (Issue #220)."""

+ 318 - 0
backend/tests/integration/test_scheduler_budget_reservation.py

@@ -0,0 +1,318 @@
+"""Budget-reservation lifecycle through the unified queue scheduler."""
+
+from contextlib import ExitStack
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+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, UserWallet
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.finance_budget import validate_print_budget
+from backend.app.services.print_scheduler import PrintScheduler
+
+pytestmark = pytest.mark.integration
+
+
+@pytest.fixture
+async def billing_dispatch_case(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    base_dir = tmp_path / "billing-dispatch"
+    archive_rel = Path("archives") / "job.3mf"
+    archive_abs = base_dir / archive_rel
+    archive_abs.parent.mkdir(parents=True)
+    archive_abs.write_bytes(b"archive payload")
+
+    async with session_maker() as db:
+        db.add(Settings(key="billing_enabled", value="true"))
+        user = User(username="scheduler-budget-admin", role="admin", is_active=True)
+        cost_center = CostCenter(name="Scheduler Budget", is_active=True, monthly_budget=10.0)
+        printer = Printer(
+            name="Budget Printer",
+            serial_number="BUDGET-SERIAL",
+            ip_address="127.0.0.1",
+            access_code="access-code",
+            model="X1C",
+        )
+        db.add_all([user, cost_center, printer])
+        await db.flush()
+
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="job.3mf",
+            file_path=str(archive_rel),
+            file_size=archive_abs.stat().st_size,
+            status="completed",
+            cost=4.0,
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db.add(archive)
+        await db.flush()
+
+        item = PrintQueueItem(
+            printer_id=printer.id,
+            archive_id=archive.id,
+            cost_center_id=cost_center.id,
+            estimated_cost=4.0,
+            created_by_id=user.id,
+            status="pending",
+        )
+        db.add(item)
+        await db.commit()
+
+        ids = SimpleNamespace(
+            user_id=user.id,
+            cost_center_id=cost_center.id,
+            printer_id=printer.id,
+            archive_id=archive.id,
+            item_id=item.id,
+        )
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
+    finally:
+        await engine.dispose()
+
+
+async def _dispatch(ctx, *, uploaded: bool = True, cancel_during_upload: bool = False):
+    scheduler = PrintScheduler()
+    start_print = MagicMock(return_value=True)
+
+    async def upload(*_args, **_kwargs):
+        if cancel_during_upload:
+            async with ctx.session_maker() as other_db:
+                item = await other_db.get(PrintQueueItem, ctx.ids.item_id)
+                item.status = "cancelled"
+                await other_db.commit()
+        return uploaded
+
+    patches = [
+        patch.object(scheduler_module, "async_session", ctx.session_maker),
+        patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
+        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+        patch(
+            "backend.app.services.print_scheduler.get_ftp_retry_settings",
+            AsyncMock(return_value=(False, 0, 0, 1.0)),
+        ),
+        patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.upload_file_async", upload),
+        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
+        patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+        patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+    ]
+    with ExitStack() as stack:
+        for patcher in patches:
+            stack.enter_context(patcher)
+        await scheduler._dispatch_one(ctx.ids.item_id)
+
+    return start_print
+
+
+async def _reservation(ctx):
+    async with ctx.session_maker() as db:
+        return await db.scalar(
+            select(BudgetReservation).where(
+                BudgetReservation.source_type == "print_queue",
+                BudgetReservation.source_id == ctx.ids.item_id,
+            )
+        )
+
+
+@pytest.mark.asyncio
+async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billing_dispatch_case):
+    start_print = await _dispatch(billing_dispatch_case)
+
+    reservation = await _reservation(billing_dispatch_case)
+    assert reservation is not None
+    assert reservation.status == "active"
+    assert reservation.amount == 4.0
+    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:
+        user = await db.get(User, billing_dispatch_case.ids.user_id)
+        second = PrintQueueItem(
+            printer_id=billing_dispatch_case.ids.printer_id,
+            archive_id=billing_dispatch_case.ids.archive_id,
+            cost_center_id=billing_dispatch_case.ids.cost_center_id,
+            estimated_cost=6.0,
+            created_by_id=user.id,
+            status="pending",
+        )
+        db.add(second)
+        await db.commit()
+        await validate_print_budget(
+            db,
+            cost_center_id=second.cost_center_id,
+            estimated_cost=6.0,
+            current_user=user,
+            exclude_queue_item_id=second.id,
+        )
+        with pytest.raises(HTTPException, match="exceeds available"):
+            await validate_print_budget(
+                db,
+                cost_center_id=second.cost_center_id,
+                estimated_cost=6.01,
+                current_user=user,
+                exclude_queue_item_id=second.id,
+            )
+
+
+@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)
+
+    reservation = await _reservation(billing_dispatch_case)
+    assert reservation is not None
+    assert reservation.status == "released"
+    assert reservation.released_at is not None
+    start_print.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_retried_dispatch_reuses_active_reservation(billing_dispatch_case):
+    await _dispatch(billing_dispatch_case)
+
+    # Simulate startup recovery after the process stopped with a persisted
+    # reservation and the queue row was made dispatchable again.
+    async with billing_dispatch_case.session_maker() as db:
+        item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
+        item.status = "pending"
+        item.started_at = None
+        item.dispatching_at = None
+        await db.commit()
+
+    await _dispatch(billing_dispatch_case)
+
+    async with billing_dispatch_case.session_maker() as db:
+        reservations = (
+            (
+                await db.execute(
+                    select(BudgetReservation).where(
+                        BudgetReservation.source_type == "print_queue",
+                        BudgetReservation.source_id == billing_dispatch_case.ids.item_id,
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+    assert len(reservations) == 1
+    assert reservations[0].status == "active"
+
+
+@pytest.mark.asyncio
+async def test_cancel_during_upload_releases_scheduler_reservation(billing_dispatch_case):
+    start_print = await _dispatch(billing_dispatch_case, cancel_during_upload=True)
+
+    reservation = await _reservation(billing_dispatch_case)
+    assert reservation is not None
+    assert reservation.status == "released"
+    assert reservation.released_at is not None
+    start_print.assert_not_called()
+
+    async with billing_dispatch_case.session_maker() as db:
+        item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
+        active_count = await db.scalar(
+            select(func.count()).select_from(BudgetReservation).where(BudgetReservation.status == "active")
+        )
+    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

@@ -6765,6 +6765,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.
 

+ 744 - 0
backend/tests/unit/services/test_finance_service_billing.py

@@ -0,0 +1,744 @@
+"""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 BillingRunIdCollisionError, apply_print_charge_for_archive
+
+
+async def enable_billing(db_session):
+    setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
+    if setting is None:
+        db_session.add(Settings(key="billing_enabled", value="true"))
+    else:
+        setting.value = "true"
+    await db_session.commit()
+
+
+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)
+        user = User(username="printer", role="user", is_active=True)
+        archive_cost_center = CostCenter(name="Archive CC", is_active=True, is_private=False)
+        override_cost_center = CostCenter(name="Override CC", is_active=True, is_private=False)
+        db_session.add_all([user, archive_cost_center, override_cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(archive_cost_center)
+        await db_session.refresh(override_cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="test.3mf",
+            file_path="archives/test/test.3mf",
+            file_size=123,
+            content_hash="hash-1",
+            status="completed",
+            cost=7.5,
+            created_by_id=user.id,
+            cost_center_id=archive_cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            cost_center_id=override_cost_center.id,
+            print_run_id="run-1",
+        )
+        await db_session.commit()
+
+        assert changed is True
+        assert archive.cost_center_id == archive_cost_center.id
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+
+        tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-1"))
+        assert tx is not None
+        assert tx.cost_center_id == override_cost_center.id
+        assert tx.print_archive_id == archive.id
+
+        duplicate = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            cost_center_id=override_cost_center.id,
+            print_run_id="run-1",
+        )
+        assert duplicate is False
+
+        second_run = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            cost_center_id=override_cost_center.id,
+            print_run_id="run-2",
+        )
+        await db_session.commit()
+
+        assert second_run is True
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+
+        rows = (
+            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
+            .scalars()
+            .all()
+        )
+        assert len(rows) == 2
+        assert {row.print_run_id for row in rows} == {"run-1", "run-2"}
+
+    @pytest.mark.asyncio
+    async def test_apply_print_charge_consumes_matching_budget_reservation(self, db_session):
+        await enable_billing(db_session)
+        user = User(username="reserved", role="user", is_active=True)
+        cost_center = CostCenter(name="Reserved CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="reserved.3mf",
+            file_path="archives/test/reserved.3mf",
+            file_size=123,
+            content_hash="hash-reserved",
+            status="completed",
+            cost=4.0,
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        reservation = BudgetReservation(
+            cost_center_id=cost_center.id,
+            amount=4.0,
+            status="active",
+            source_type="background_dispatch",
+            source_id=42,
+            print_archive_id=archive.id,
+        )
+        db_session.add(reservation)
+        await db_session.commit()
+        await db_session.refresh(reservation)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-reserved")
+        await db_session.commit()
+
+        assert changed is True
+        await db_session.refresh(reservation)
+        assert reservation.status == "consumed"
+        assert reservation.released_at is not None
+
+    @pytest.mark.asyncio
+    async def test_apply_print_charge_rejects_ineligible_archive(self, db_session):
+        await enable_billing(db_session)
+        user = User(username="skipped", role="user", is_active=True)
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        # Reject print with unknown status
+        archive = PrintArchive(
+            printer_id=None,
+            filename="unknown.3mf",
+            file_path="archives/test/unknown.3mf",
+            file_size=123,
+            content_hash="hash-2",
+            status="unknown",
+            cost=1.0,
+            created_by_id=user.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-unknown")
+        assert changed is False
+
+    @pytest.mark.asyncio
+    async def test_apply_print_charge_skips_when_billing_disabled(self, db_session):
+        user = User(username="billing_disabled", role="user", is_active=True)
+        cost_center = CostCenter(name="Disabled Billing CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="billing-disabled.3mf",
+            file_path="archives/test/billing-disabled.3mf",
+            file_size=123,
+            content_hash="hash-disabled-billing",
+            status="completed",
+            cost=7.5,
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        reservation = BudgetReservation(
+            cost_center_id=cost_center.id,
+            amount=7.5,
+            status="active",
+            source_type="background_dispatch",
+            source_id=123,
+            print_archive_id=archive.id,
+        )
+        db_session.add(reservation)
+        await db_session.commit()
+        await db_session.refresh(reservation)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-disabled")
+        await db_session.commit()
+
+        assert changed is False
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-disabled"))
+        assert wallet is None
+        assert tx is None
+        await db_session.refresh(reservation)
+        assert reservation.status == "released"
+        assert reservation.released_at is not None
+
+
+class TestPartialPrintCharges:
+    """Tests for proportional charge calculation on aborted/failed/cancelled prints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("status", ["cancelled", "aborted", "failed"])
+    async def test_terminal_partial_print_uses_per_run_consumption_and_consumes_reservation(
+        self,
+        db_session,
+        status,
+    ):
+        """Bambuddy stop, display abort, and printer failure share one billing path."""
+        await enable_billing(db_session)
+        user = User(username=f"partial_{status}", role="user", is_active=True)
+        cost_center = CostCenter(name=f"Partial {status} CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename=f"{status}.3mf",
+            file_path=f"archives/test/{status}.3mf",
+            file_size=100,
+            content_hash=f"partial-{status}-override",
+            status=status,
+            # The usage tracker may already have replaced archive.cost with the
+            # measured partial cost. Completion billing must use the estimate
+            # captured before tracking, not discount this value a second time.
+            cost=3.0,
+            filament_used_grams=100.0,
+            extra_data={"filament_grams_total": 100.0},
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        reservation = BudgetReservation(
+            cost_center_id=cost_center.id,
+            amount=12.0,
+            status="active",
+            source_type="print_queue",
+            source_id=archive.id,
+            print_archive_id=archive.id,
+        )
+        db_session.add(reservation)
+        await db_session.commit()
+        await db_session.refresh(reservation)
+
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            base_cost_override=12.0,
+            filament_usage=(25.0, 100.0),
+        )
+        await db_session.commit()
+
+        assert changed is True
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+
+        transaction = await db_session.scalar(
+            select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
+        )
+        assert transaction is not None
+        assert transaction.amount == -3.0
+        assert status in transaction.description.lower()
+        assert "25.0g/100.0g" in transaction.description
+
+        await db_session.refresh(reservation)
+        assert reservation.status == "consumed"
+        assert reservation.released_at is not None
+
+    @pytest.mark.asyncio
+    async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
+        await enable_billing(db_session)
+        user = User(username="missing_plan", role="user", is_active=True)
+        cost_center = CostCenter(name="Missing Plan CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="missing-plan.3mf",
+            file_path="archives/test/missing-plan.3mf",
+            file_size=100,
+            content_hash="missing-plan-hash",
+            status="aborted",
+            cost=12.0,
+            filament_used_grams=80.0,
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id)
+        await db_session.commit()
+
+        assert changed is False
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is None
+
+    @pytest.mark.asyncio
+    async def test_invalid_transaction_type_is_rejected(self, db_session):
+        user = User(username="invalid_tx", role="user", is_active=True)
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        with pytest.raises(ValueError, match="Invalid transaction type"):
+            WalletTransaction(
+                user_id=user.id,
+                transaction_type="not-a-real-type",
+                amount=1.0,
+            )
+
+    @pytest.mark.asyncio
+    async def test_aborted_print_with_partial_filament_charges_proportionally(self, db_session):
+        """Verify aborted print charges proportionally based on filament used."""
+        await enable_billing(db_session)
+        user = User(username="abort_test", role="user", is_active=True)
+        cost_center = CostCenter(name="Abort CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        # Archive with 100g planned, but only 50g used (50% filament)
+        archive = PrintArchive(
+            printer_id=None,
+            filename="abort.3mf",
+            file_path="archives/test/abort.3mf",
+            file_size=100,
+            content_hash="abort-hash",
+            status="aborted",
+            cost=10.0,  # Full cost would be 10.0
+            filament_used_grams=50.0,
+            extra_data={"filament_grams_total": 100.0},
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id)
+        await db_session.commit()
+
+        assert changed is True
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+
+        tx = await db_session.scalar(
+            select(WalletTransaction)
+            .where(WalletTransaction.user_id == user.id)
+            .where(WalletTransaction.transaction_type == "print_charge")
+        )
+        assert tx is not None
+        assert tx.amount == -5.0
+        assert "aborted" in tx.description.lower()
+        assert "50.0" in tx.description  # filament used
+
+    @pytest.mark.asyncio
+    async def test_cancelled_print_with_zero_run_usage_is_not_charged(self, db_session):
+        """A slicer estimate alone is not mistaken for actual run consumption."""
+        await enable_billing(db_session)
+        user = User(username="cancel_no_data", role="user", is_active=True)
+        cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="cancel.3mf",
+            file_path="archives/test/cancel.3mf",
+            file_size=100,
+            content_hash="cancel-hash",
+            status="cancelled",
+            cost=5.0,
+            filament_used_grams=100.0,
+            extra_data={"filament_grams_total": 100.0},
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        reservation = BudgetReservation(
+            cost_center_id=cost_center.id,
+            amount=5.0,
+            status="active",
+            source_type="background_dispatch",
+            source_id=99,
+            print_archive_id=archive.id,
+        )
+        db_session.add(reservation)
+        await db_session.commit()
+        await db_session.refresh(reservation)
+
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            filament_usage=(None, 100.0),
+        )
+        await db_session.commit()
+
+        assert changed is False
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is None  # No wallet created
+        await db_session.refresh(reservation)
+        assert reservation.status == "released"
+        assert reservation.released_at is not None
+
+    @pytest.mark.asyncio
+    async def test_failed_print_with_minimal_filament_charges_small_amount(self, db_session):
+        """Verify failed print with minimal filament usage charges proportionally."""
+        await enable_billing(db_session)
+        user = User(username="fail_min", role="user", is_active=True)
+        cost_center = CostCenter(name="Fail Min CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        # 5% filament used out of 100g planned
+        archive = PrintArchive(
+            printer_id=None,
+            filename="fail_min.3mf",
+            file_path="archives/test/fail_min.3mf",
+            file_size=100,
+            content_hash="fail-min-hash",
+            status="failed",
+            cost=20.0,
+            filament_used_grams=5.0,
+            extra_data={"filament_grams_total": 100.0},
+            failure_reason="Filament runout",
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id)
+        await db_session.commit()
+
+        assert changed is True
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+
+    @pytest.mark.asyncio
+    async def test_completed_print_still_charges_full_cost(self, db_session):
+        """Verify completed prints ignore filament ratio and charge full cost."""
+        await enable_billing(db_session)
+        user = User(username="completed_full", role="user", is_active=True)
+        cost_center = CostCenter(name="Completed Full CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="complete.3mf",
+            file_path="archives/test/complete.3mf",
+            file_size=100,
+            content_hash="complete-hash",
+            status="completed",
+            cost=15.0,
+            filament_used_grams=100.0,
+            extra_data={"filament_grams_total": 100.0},
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id)
+        await db_session.commit()
+
+        assert changed is True
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet.balance == 0.0
+
+    @pytest.mark.asyncio
+    async def test_partial_charge_with_cost_center_override(self, db_session):
+        """Verify partial charges respect cost_center_id override."""
+        await enable_billing(db_session)
+        user = User(username="partial_cc", role="user", is_active=True)
+        default_cc = CostCenter(name="Default", is_active=True, is_private=False)
+        override_cc = CostCenter(name="Override", is_active=True, is_private=False)
+        db_session.add_all([user, default_cc, override_cc])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(default_cc)
+        await db_session.refresh(override_cc)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename="partial_cc.3mf",
+            file_path="archives/test/partial_cc.3mf",
+            file_size=100,
+            content_hash="partial-cc-hash",
+            status="aborted",
+            cost=8.0,
+            filament_used_grams=25.0,
+            extra_data={"filament_grams_total": 100.0},
+            cost_center_id=default_cc.id,
+            created_by_id=user.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        changed = await apply_print_charge_for_archive(db_session, archive.id, cost_center_id=override_cc.id)
+        await db_session.commit()
+
+        assert changed is True
+
+        tx = await db_session.scalar(
+            select(WalletTransaction)
+            .where(WalletTransaction.user_id == user.id)
+            .where(WalletTransaction.transaction_type == "print_charge")
+        )
+        assert tx is not None
+        assert tx.cost_center_id == override_cc.id
+        assert tx.amount == -2.0  # 25% of 8.0

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

@@ -0,0 +1,92 @@
+"""Unit tests for finance defaults applied during user creation/update."""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
+
+
+class TestFinanceDefaults:
+    @pytest.mark.asyncio
+    async def test_creates_wallet_private_center_and_membership(self, db_session):
+        db_session.add(Settings(key="currency", value="USD"))
+
+        user = User(username="alice", role="user", is_active=True)
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        changed = await ensure_user_finance_defaults(db_session, user)
+        await db_session.commit()
+
+        assert changed is True
+
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == 0.0
+        assert wallet.currency == "USD"
+
+        center = await db_session.scalar(
+            select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
+        )
+        assert center is not None
+        assert center.name == "alice"
+
+        membership = await db_session.scalar(
+            select(CostCenterMember).where(
+                CostCenterMember.cost_center_id == center.id,
+                CostCenterMember.user_id == user.id,
+            )
+        )
+        assert membership is not None
+        assert membership.can_print is True
+
+    @pytest.mark.asyncio
+    async def test_updates_private_center_name_and_is_idempotent(self, db_session):
+        user = User(username="bob", role="user", is_active=True)
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        initial_changed = await ensure_user_finance_defaults(db_session, user)
+        await db_session.commit()
+
+        assert initial_changed is True
+
+        user.username = "bobby"
+        renamed_changed = await ensure_user_finance_defaults(db_session, user)
+        await db_session.commit()
+
+        assert renamed_changed is True
+
+        center = await db_session.scalar(
+            select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
+        )
+        assert center is not None
+        assert center.name == "bobby"
+
+        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)
     # ========================================================================

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

@@ -0,0 +1,101 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from backend.app.services import print_cost_estimate
+
+
+@pytest.fixture
+def library_file(tmp_path):
+    path = tmp_path / "queued.gcode.3mf"
+    path.write_bytes(b"stub")
+    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(
+        print_cost_estimate.threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda *_args: SimpleNamespace(
+            filament_usage=[
+                {"slot_id": 1, "used_g": 100.0},
+                {"slot_id": 2, "used_g": 50.0},
+            ]
+        ),
+    )
+    monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
+
+    cost = await print_cost_estimate.estimate_queue_source_cost(
+        SimpleNamespace(),
+        library_file=library_file,
+        plate_id=1,
+    )
+
+    assert cost == 3.0
+
+
+@pytest.mark.asyncio
+async def test_library_estimate_uses_server_spool_assignment_costs(monkeypatch, library_file):
+    monkeypatch.setattr(
+        print_cost_estimate.threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda *_args: SimpleNamespace(
+            filament_usage=[
+                {"slot_id": 1, "used_g": 100.0},
+                {"slot_id": 2, "used_g": 50.0},
+            ]
+        ),
+    )
+    monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
+
+    assignments = [
+        SimpleNamespace(ams_id=0, tray_id=0, spool=SimpleNamespace(cost_per_kg=10.0)),
+        SimpleNamespace(ams_id=0, tray_id=1, spool=SimpleNamespace(cost_per_kg=30.0)),
+    ]
+    result = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: assignments))
+    db = SimpleNamespace(execute=AsyncMock(return_value=result))
+
+    cost = await print_cost_estimate.estimate_queue_source_cost(
+        db,
+        library_file=library_file,
+        plate_id=1,
+        ams_mapping=[0, 1],
+        printer_id=7,
+    )
+
+    assert cost == 2.5
+
+
+@pytest.mark.asyncio
+async def test_missing_server_metadata_does_not_fall_back_to_client_hint(monkeypatch, library_file):
+    monkeypatch.setattr(
+        print_cost_estimate.threemf_tools,
+        "extract_plate_metadata_from_3mf",
+        lambda *_args: SimpleNamespace(filament_usage=[]),
+    )
+
+    cost = await print_cost_estimate.estimate_queue_source_cost(
+        SimpleNamespace(),
+        library_file=library_file,
+        plate_id=1,
+    )
+
+    assert cost is None

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

@@ -0,0 +1,52 @@
+"""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.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """The engine below is SQLite, but settings.database_url may point at Postgres in a
+    dev config — and run_migrations branches on the global dialect, not on the
+    connection. Without this the Postgres branch runs against SQLite and the migration
+    fails on Postgres-only syntax. Same fixture as test_ldap_migration.py."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    # database.py imported is_sqlite at module load time — patch there too.
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@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()

+ 241 - 0
backend/tests/unit/test_finance_table_migration.py

@@ -0,0 +1,241 @@
+"""Regression tests for finance tables on upgraded databases."""
+
+import os
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import (
+    _migrate_add_print_archive_cost_center,
+    _migrate_create_finance_indexes,
+    _migrate_create_finance_tables,
+    _migrate_finance_money_to_numeric,
+)
+
+EXPECTED_TABLES = {
+    "cost_centers",
+    "wallet_transactions",
+    "budget_reservations",
+    "cost_center_members",
+    "user_wallets",
+}
+
+
+@pytest.mark.asyncio
+async def test_finance_tables_are_created_idempotently_on_sqlite():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+
+    try:
+        async with engine.begin() as conn:
+            with patch("backend.app.core.database.is_sqlite", return_value=True):
+                await _migrate_create_finance_tables(conn)
+                await _migrate_create_finance_tables(conn)
+
+            rows = await conn.execute(
+                text(
+                    "SELECT name FROM sqlite_master "
+                    "WHERE type = 'table' AND name IN "
+                    "('cost_centers', 'wallet_transactions', 'budget_reservations', "
+                    "'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()
+
+
+@pytest.mark.asyncio
+async def test_legacy_cost_center_indexes_are_delayed_until_columns_exist():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+
+    try:
+        async with engine.begin() as conn:
+            await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, name VARCHAR(150) NOT NULL)"))
+
+            with patch("backend.app.core.database.is_sqlite", return_value=True):
+                await _migrate_create_finance_tables(conn)
+
+            await conn.execute(text("ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)"))
+            await conn.execute(text("CREATE INDEX ix_cost_centers_code ON cost_centers (code)"))
+            await conn.execute(text("INSERT INTO cost_centers (id, name, code) VALUES (1, 'One', 'one')"))
+            await _migrate_create_finance_indexes(conn)
+
+            result = await conn.execute(text("PRAGMA index_list(cost_centers)"))
+            code_index = next(row for row in result if row[1] == "ix_cost_centers_code")
+
+            with pytest.raises(IntegrityError):
+                await conn.execute(text("INSERT INTO cost_centers (id, name, code) VALUES (2, 'Two', 'one')"))
+
+        assert code_index[2] == 1
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_print_archive_cost_center_is_added_idempotently_on_sqlite():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+
+    try:
+        async with engine.begin() as conn:
+            await conn.execute(text("PRAGMA foreign_keys = ON"))
+            await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY)"))
+            await conn.execute(text("CREATE TABLE print_archives (id INTEGER PRIMARY KEY)"))
+
+            await _migrate_add_print_archive_cost_center(conn)
+            await _migrate_add_print_archive_cost_center(conn)
+
+            columns = await conn.execute(text("PRAGMA table_info(print_archives)"))
+            foreign_keys = await conn.execute(text("PRAGMA foreign_key_list(print_archives)"))
+
+        assert "cost_center_id" in {row[1] for row in columns}
+        assert any(
+            row[2] == "cost_centers" and row[3] == "cost_center_id" and row[6].upper() == "SET NULL"
+            for row in foreign_keys
+        )
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_postgres_finance_ddl_uses_postgres_types():
+    statements: list[str] = []
+
+    async def capture_statement(_conn, sql: str) -> None:
+        statements.append(sql)
+
+    with (
+        patch("backend.app.core.database.is_sqlite", return_value=False),
+        patch("backend.app.core.database._safe_execute", side_effect=capture_statement),
+    ):
+        await _migrate_create_finance_tables(object())
+
+    create_statements = [sql for sql in statements if "CREATE TABLE" in sql]
+    assert len(create_statements) == len(EXPECTED_TABLES)
+    assert all("IF NOT EXISTS" in sql for sql in create_statements)
+    assert all("DATETIME" not in sql for sql in create_statements)
+    assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
+    assert "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
+    }
+    assert created_tables == EXPECTED_TABLES
+
+
+@pytest.mark.asyncio
+async def test_postgres_finance_money_columns_are_migrated_to_numeric():
+    statements: list[str] = []
+
+    async def capture_statement(_conn, sql: str) -> None:
+        statements.append(sql)
+
+    with (
+        patch("backend.app.core.database.is_sqlite", return_value=False),
+        patch("backend.app.core.database._safe_execute", side_effect=capture_statement),
+    ):
+        await _migrate_finance_money_to_numeric(object())
+
+    assert len(statements) == 6
+    assert all("TYPE NUMERIC(14,2)" in sql for sql in statements)
+    assert all("USING ROUND(" in sql for sql in statements)
+    assert any("wallet_transactions ALTER COLUMN amount" in sql for sql in statements)
+    assert any("wallet_transactions ALTER COLUMN balance_after" in sql for sql in statements)
+
+
+@pytest.mark.asyncio
+async def test_finance_tables_are_created_idempotently_on_postgres():
+    database_url = os.getenv("BAMBUDDY_TEST_POSTGRES_URL")
+    if not database_url:
+        pytest.skip("BAMBUDDY_TEST_POSTGRES_URL is not configured")
+
+    engine = create_async_engine(database_url)
+    try:
+        async with engine.begin() as conn:
+            # Minimal pre-billing schema: these are the only tables referenced
+            # by foreign keys in the new finance tables.
+            await conn.execute(text("CREATE TABLE users (id SERIAL PRIMARY KEY)"))
+            await conn.execute(text("CREATE TABLE print_archives (id SERIAL PRIMARY KEY)"))
+            await conn.execute(text("CREATE TABLE print_queue (id SERIAL PRIMARY KEY)"))
+
+            with patch("backend.app.core.database.is_sqlite", return_value=False):
+                await _migrate_create_finance_tables(conn)
+                await _migrate_create_finance_tables(conn)
+                await conn.execute(
+                    text(
+                        "ALTER TABLE wallet_transactions ALTER COLUMN amount "
+                        "TYPE DOUBLE PRECISION USING amount::double precision"
+                    )
+                )
+                await _migrate_finance_money_to_numeric(conn)
+                await _migrate_finance_money_to_numeric(conn)
+                await _migrate_add_print_archive_cost_center(conn)
+                await _migrate_add_print_archive_cost_center(conn)
+                await _migrate_create_finance_indexes(conn)
+                await _migrate_create_finance_indexes(conn)
+
+            rows = await conn.execute(
+                text(
+                    "SELECT table_name FROM information_schema.tables "
+                    "WHERE table_schema = 'public' AND table_name = ANY(:tables)"
+                ),
+                {"tables": sorted(EXPECTED_TABLES)},
+            )
+            timestamp_type = await conn.execute(
+                text(
+                    "SELECT data_type FROM information_schema.columns "
+                    "WHERE table_schema = 'public' "
+                    "AND table_name = 'cost_centers' AND column_name = 'created_at'"
+                )
+            )
+            money_types = await conn.execute(
+                text(
+                    "SELECT table_name, column_name, data_type, numeric_precision, numeric_scale "
+                    "FROM information_schema.columns "
+                    "WHERE table_schema = 'public' AND (table_name, column_name) IN ("
+                    "('cost_centers', 'total_budget'), ('cost_centers', 'monthly_budget'), "
+                    "('user_wallets', 'balance'), ('wallet_transactions', 'amount'), "
+                    "('wallet_transactions', 'balance_after'), ('budget_reservations', 'amount'))"
+                )
+            )
+            money_type_rows = money_types.all()
+            archive_cost_center = await conn.execute(
+                text(
+                    "SELECT c.data_type, rc.delete_rule "
+                    "FROM information_schema.columns c "
+                    "JOIN information_schema.key_column_usage kcu "
+                    "  ON kcu.table_schema = c.table_schema "
+                    " AND kcu.table_name = c.table_name "
+                    " AND kcu.column_name = c.column_name "
+                    "JOIN information_schema.referential_constraints rc "
+                    "  ON rc.constraint_schema = kcu.constraint_schema "
+                    " AND rc.constraint_name = kcu.constraint_name "
+                    "WHERE c.table_schema = 'public' "
+                    "AND c.table_name = 'print_archives' "
+                    "AND c.column_name = 'cost_center_id'"
+                )
+            )
+
+        assert {row[0] for row in rows} == EXPECTED_TABLES
+        assert timestamp_type.scalar_one() == "timestamp without time zone"
+        assert len(money_type_rows) == 6
+        assert all(row[2:] == ("numeric", 14, 2) for row in money_type_rows)
+        assert archive_cost_center.one() == ("integer", "SET NULL")
+    finally:
+        await engine.dispose()

+ 468 - 0
backend/tests/unit/test_printer_kill_switch.py

@@ -0,0 +1,468 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+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()
+    main_module._active_prints.clear()
+    main_module._expected_print_registered_at.clear()
+    main_module._printer_reconciled_since_connect.clear()
+    yield
+    for task in main_module._kill_switch_notification_tasks.values():
+        if not task.done():
+            task.cancel()
+    main_module._unauthorized_print_kill_sent.clear()
+    main_module._kill_switch_notification_tasks.clear()
+    main_module._expected_prints.clear()
+    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():
+    state = SimpleNamespace(
+        current_print=None,
+        subtask_name="",
+        gcode_file="foreign_job.gcode.3mf",
+    )
+
+    keys = main_module._build_status_print_keys(7, state)
+
+    assert (7, "foreign_job.gcode.3mf") in keys
+    assert (7, "foreign_job.gcode") in keys
+
+
+@pytest.mark.asyncio
+async def test_unauthorized_active_print_triggers_stop(monkeypatch):
+    stop_calls: list[int] = []
+    broadcast = AsyncMock()
+    provider_notification = AsyncMock(return_value=True)
+
+    async def fake_status(*args, **kwargs):
+        return None
+
+    async def kill_switch_enabled(_db):
+        return True
+
+    unauthorized = AsyncMock(return_value=False)
+
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    monkeypatch.setattr(
+        main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
+    )
+    monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
+    monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
+    monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
+    monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
+    monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
+    monkeypatch.setattr(main_module.ws_manager, "broadcast", broadcast)
+    monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
+    monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", provider_notification)
+    monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
+
+    state = SimpleNamespace(
+        connected=True,
+        state="RUNNING",
+        progress=0,
+        remaining_time=0,
+        layer_num=0,
+        temperatures={},
+        raw_data={},
+        stg_cur=0,
+        cooling_fan_speed=None,
+        big_fan1_speed=None,
+        big_fan2_speed=None,
+        chamber_light=False,
+        active_extruder=0,
+        tray_now=255,
+        door_open=False,
+        ams_filament_backup=False,
+        current_print=None,
+        subtask_name="foreign_job",
+        subtask_id="external-task-1",
+        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(
+        {
+            "type": "kill_switch_triggered",
+            "printer_id": 7,
+            "printer_name": "Printer 7",
+            "filename": "foreign_job",
+            "reason": "unauthorized_print",
+        }
+    )
+    notification_task = main_module._kill_switch_notification_tasks[7]
+    assert await notification_task is True
+    provider_notification.assert_awaited_once_with(
+        7,
+        "Printer 7",
+        {
+            "status": "stopped",
+            "filename": "foreign_job.gcode",
+            "subtask_name": "foreign_job",
+            "progress": 0,
+            "reason": "unauthorized_print",
+        },
+    )
+
+
+@pytest.mark.asyncio
+async def test_failed_immediate_notification_allows_completion_retry():
+    task = main_module.spawn_background_task(_return_false(), name="test-kill-switch-notification-failure")
+
+    assert await main_module._kill_switch_notification_already_sent(task) is False
+
+
+async def _return_false():
+    return False
+
+
+@pytest.mark.asyncio
+async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
+    monkeypatch.setitem(main_module._expected_prints, (7, "foreign_job"), 123)
+
+    stop_calls: list[int] = []
+
+    async def fake_status(*args, **kwargs):
+        return None
+
+    kill_switch_enabled = AsyncMock(return_value=True)
+
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    monkeypatch.setattr(
+        main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
+    )
+    monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
+    monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
+    monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
+    monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
+    monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
+    monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
+
+    state = SimpleNamespace(
+        connected=True,
+        state="RUNNING",
+        progress=0,
+        remaining_time=0,
+        layer_num=0,
+        temperatures={},
+        raw_data={},
+        stg_cur=0,
+        cooling_fan_speed=None,
+        big_fan1_speed=None,
+        big_fan2_speed=None,
+        chamber_light=False,
+        active_extruder=0,
+        tray_now=255,
+        door_open=False,
+        ams_filament_backup=False,
+        current_print=None,
+        subtask_name="foreign_job",
+        gcode_file="foreign_job.gcode",
+    )
+
+    await main_module.on_printer_status_change(7, state)
+
+    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
+async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
+    stop_calls: list[int] = []
+
+    async def fake_status(*args, **kwargs):
+        return None
+
+    async def kill_switch_enabled(_db):
+        return True
+
+    async def unauthorized(*_args):
+        return False
+
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    monkeypatch.setattr(
+        main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
+    )
+    monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
+    monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
+    monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
+    monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
+    monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
+    monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
+    monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
+
+    active_state = SimpleNamespace(
+        connected=True,
+        state="RUNNING",
+        progress=0,
+        remaining_time=0,
+        layer_num=0,
+        temperatures={},
+        raw_data={},
+        stg_cur=0,
+        cooling_fan_speed=None,
+        big_fan1_speed=None,
+        big_fan2_speed=None,
+        chamber_light=False,
+        active_extruder=0,
+        tray_now=255,
+        door_open=False,
+        ams_filament_backup=False,
+        current_print=None,
+        subtask_name="foreign_job",
+        subtask_id="external-task-1",
+        gcode_file="foreign_job.gcode",
+    )
+
+    idle_state = SimpleNamespace(
+        connected=True,
+        state="IDLE",
+        progress=0,
+        remaining_time=0,
+        layer_num=0,
+        temperatures={},
+        raw_data={},
+        stg_cur=0,
+        cooling_fan_speed=None,
+        big_fan1_speed=None,
+        big_fan2_speed=None,
+        chamber_light=False,
+        active_extruder=0,
+        tray_now=255,
+        door_open=False,
+        ams_filament_backup=False,
+        current_print=None,
+        subtask_name="",
+        subtask_id=None,
+        gcode_file=None,
+    )
+
+    await main_module.on_printer_status_change(7, active_state)
+    assert stop_calls == [7]
+    assert 7 in main_module._unauthorized_print_kill_sent
+
+    await main_module.on_printer_status_change(7, idle_state)
+
+    assert 7 not in main_module._unauthorized_print_kill_sent
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("printer_state", ["RUNNING", "PAUSE"])
+async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_state):
+    # billing_run_id is the marker the scheduler stamps on its own dispatches;
+    # an archive without one proves only that Bambuddy watched the print.
+    archive = SimpleNamespace(
+        id=123,
+        filename="owned_job.gcode.3mf",
+        billing_run_id="d7c1f0b2-0000-4000-8000-000000000001",
+        created_by_id=None,
+    )
+    query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
+    db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
+
+    class FakeSessionContext:
+        async def __aenter__(self):
+            return db
+
+        async def __aexit__(self, *_args):
+            return False
+
+    stop_calls: list[int] = []
+
+    async def fake_status(*args, **kwargs):
+        return None
+
+    async def kill_switch_enabled(_db):
+        return True
+
+    def discard_background_task(coro, **_kwargs):
+        coro.close()
+
+    monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
+    monkeypatch.setattr(main_module, "spawn_background_task", discard_background_task)
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    monkeypatch.setattr(
+        main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
+    )
+    monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
+    monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
+    monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
+    monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
+    monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
+    monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
+
+    state = SimpleNamespace(
+        connected=True,
+        state=printer_state,
+        progress=42,
+        remaining_time=600,
+        layer_num=50,
+        temperatures={},
+        raw_data={},
+        stg_cur=0,
+        cooling_fan_speed=None,
+        big_fan1_speed=None,
+        big_fan2_speed=None,
+        chamber_light=False,
+        active_extruder=0,
+        tray_now=255,
+        door_open=False,
+        ams_filament_backup=False,
+        current_print=None,
+        subtask_name="owned_job",
+        subtask_id="bambuddy-task-123",
+        gcode_file="owned_job.gcode.3mf",
+    )
+
+    await main_module.on_printer_status_change(7, state)
+
+    assert stop_calls == []
+    assert (7, "owned_job.gcode.3mf") in main_module._active_prints
+    assert main_module._active_prints[(7, "owned_job.gcode.3mf")] == 123
+    assert 7 not in main_module._unauthorized_print_kill_sent
+
+
+@pytest.mark.asyncio
+async def test_kill_switch_defers_when_restart_identity_is_not_available(monkeypatch):
+    state = SimpleNamespace(
+        current_print=None,
+        subtask_name="owned_job",
+        subtask_id=None,
+        gcode_file="owned_job.gcode.3mf",
+    )
+    db = SimpleNamespace(execute=AsyncMock())
+
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+
+    authorization = await main_module._is_bambuddy_authorized_print(7, state, db)
+
+    assert authorization is None
+    db.execute.assert_not_awaited()
+
+
+def _authorization_db(archive, dispatched_queue_item_id=None):
+    """Fake session answering the two lookups `_is_bambuddy_authorized_print` makes."""
+
+    query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
+    return SimpleNamespace(
+        execute=AsyncMock(return_value=query_result),
+        scalar=AsyncMock(return_value=dispatched_queue_item_id),
+    )
+
+
+def _running_state(subtask_id="external-task-9"):
+    return SimpleNamespace(
+        current_print=None,
+        subtask_name="some_job",
+        subtask_id=subtask_id,
+        gcode_file="some_job.gcode.3mf",
+    )
+
+
+@pytest.mark.asyncio
+async def test_archive_without_a_dispatch_marker_is_not_authorization(monkeypatch):
+    """on_print_start archives prints started from Studio or Handy too.
+
+    Those rows carry the same status and subtask_id as Bambuddy's own, so treating
+    the row's existence as proof would switch the feature off a few seconds into
+    every foreign print — as soon as the 3MF finished downloading.
+    """
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    observed_only = SimpleNamespace(
+        id=55,
+        filename="some_job.gcode.3mf",
+        billing_run_id=None,
+        created_by_id=None,
+    )
+    db = _authorization_db(observed_only, dispatched_queue_item_id=None)
+
+    assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False
+    assert (9, "some_job.gcode.3mf") not in main_module._active_prints
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "marker",
+    [
+        {"billing_run_id": "9f0c2b6e-0000-4000-8000-00000000abcd", "created_by_id": None},
+        {"billing_run_id": None, "created_by_id": 4},
+    ],
+    ids=["billing_run_id", "created_by_id"],
+)
+async def test_either_dispatch_marker_authorizes_after_a_restart(monkeypatch, marker):
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    archive = SimpleNamespace(id=77, filename="some_job.gcode.3mf", **marker)
+    db = _authorization_db(archive, dispatched_queue_item_id=None)
+
+    assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is True
+    assert main_module._active_prints[(9, "some_job.gcode.3mf")] == 77
+    # The fast path is rehydrated, so the queue is never consulted.
+    db.scalar.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_defers_while_bambuddy_has_a_job_running_on_that_printer(monkeypatch):
+    """A library-file dispatch has no archive at send time, and the row created for
+    it moments later by on_print_start carries neither marker. The queue row is the
+    only durable trace, and it cannot be tied to a subtask_id — so it defers."""
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    unmarked = SimpleNamespace(id=56, filename="some_job.gcode.3mf", billing_run_id=None, created_by_id=None)
+    db = _authorization_db(unmarked, dispatched_queue_item_id=310)
+
+    assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
+    # Deferring must not authorize the print for every later frame.
+    assert (9, "some_job.gcode.3mf") not in main_module._active_prints
+
+
+@pytest.mark.asyncio
+async def test_defers_when_the_dispatch_has_not_been_archived_yet(monkeypatch):
+    """Restart during the window between the MQTT send and the 3MF download."""
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    db = _authorization_db(None, dispatched_queue_item_id=311)
+
+    assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
+
+
+@pytest.mark.asyncio
+async def test_foreign_print_with_no_archive_and_no_dispatch_is_unauthorized(monkeypatch):
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    db = _authorization_db(None, dispatched_queue_item_id=None)
+
+    assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False

+ 1 - 0
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -118,6 +118,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
         original_filename,
         created_by_id=None,
         project_id=None,
+        cost_center_id=None,
         plate_id=None,
         library_file_id=None,
     ):

+ 5 - 3
backend/tests/unit/test_timelapse_baseline_restart_recovery.py

@@ -98,7 +98,8 @@ async def test_running_observed_skips_when_baseline_already_present():
     """If on_print_start already ran in this Bambuddy process for the same
     printer (the realistic same-session race), a second capture would
     overwrite the correct pre-print baseline with one taken later — which
-    could include the in-flight MP4. Skip when a baseline exists."""
+    could include the in-flight MP4. The archive lookup still has to run so
+    restart recovery can restore durable print ownership for the kill switch."""
     _timelapse_baselines[1] = {"pre_existing_a.mp4", "pre_existing_b.mp4"}
 
     with (
@@ -118,8 +119,9 @@ async def test_running_observed_skips_when_baseline_already_present():
             },
         )
 
-        # Neither the DB lookup nor the FTP scan should have run.
-        mock_session_maker.assert_not_called()
+        # Ownership reconciliation still consults the DB, but the expensive
+        # timelapse scan remains one-shot.
+        mock_session_maker.assert_called_once()
         mock_list.assert_not_called()
 
     # Original baseline preserved.

+ 2 - 0
frontend/src/App.tsx

@@ -7,6 +7,7 @@ import { ArchivesPage } from './pages/ArchivesPage';
 import { QueuePage } from './pages/QueuePage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
+import { FinancePage } from './pages/FinancePage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
@@ -211,6 +212,7 @@ function App() {
                   <Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
+                  <Route path="finance" element={<PermissionRoute permission="cost_centers:read_own"><FinancePage /></PermissionRoute>} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />

+ 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

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

@@ -0,0 +1,232 @@
+/**
+ * Tests for billing-related PrintModal request payloads.
+ */
+
+import React from 'react';
+import { BrowserRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import { server } from '../mocks/server';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+
+const mockShowToast = vi.fn();
+const mockUseAuth = {
+  user: { id: 1, username: 'finance-user', permissions: ['cost_centers:read_own', 'printers:control'] },
+  authEnabled: true,
+  requiresSetup: false,
+  loading: false,
+  isAdmin: false,
+  login: vi.fn(),
+  loginWithToken: vi.fn(),
+  logout: vi.fn(),
+  refreshUser: vi.fn(),
+  refreshAuth: vi.fn(),
+  hasPermission: vi.fn((permission: string) => permission === 'cost_centers:read_own' || permission === 'printers:control'),
+  hasAnyPermission: vi.fn(() => true),
+  hasAllPermissions: vi.fn(() => true),
+  canModify: vi.fn(() => true),
+};
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return {
+    ...actual,
+    useAuth: () => mockUseAuth,
+  };
+});
+
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { PrintModal } from '../../components/PrintModal';
+
+function createTestQueryClient() {
+  return new QueryClient({
+    defaultOptions: {
+      queries: { retry: false, gcTime: 0 },
+      mutations: { retry: false },
+    },
+  });
+}
+
+function renderWithProviders(ui: React.ReactElement) {
+  const queryClient = createTestQueryClient();
+  return render(
+    <QueryClientProvider client={queryClient}>
+      <BrowserRouter>
+        <ThemeProvider>{ui}</ThemeProvider>
+      </BrowserRouter>
+    </QueryClientProvider>
+  );
+}
+
+const mockPrinters = [
+  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
+];
+
+const mockQueueItem = {
+  id: 9,
+  printer_id: 1,
+  archive_id: 1,
+  position: 1,
+  scheduled_time: null,
+  require_previous_success: false,
+  auto_off_after: false,
+  gcode_injection: false,
+  manual_start: false,
+  ams_mapping: null,
+  plate_id: null,
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+  use_ams: true,
+  status: 'pending',
+  started_at: null,
+  completed_at: null,
+  error_message: null,
+  created_at: '2024-01-01T00:00:00Z',
+  archive_name: 'Billing Print',
+  archive_thumbnail: null,
+  printer_name: 'X1 Carbon',
+  print_time_seconds: 3600,
+  batch_id: null,
+  batch_name: null,
+  cost_center_id: 42,
+  estimated_cost: 12.34,
+};
+
+describe('PrintModal billing payloads', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json({
+          currency: 'USD',
+          default_filament_cost: 25,
+          billing_enabled: true,
+          default_bed_levelling: true,
+          default_flow_cali: false,
+          default_vibration_cali: true,
+          default_layer_inspect: false,
+          default_timelapse: false,
+          stagger_group_size: 2,
+          stagger_interval_minutes: 5,
+          per_printer_mapping_expanded: false,
+          date_format: 'system',
+          time_format: 'system',
+        });
+      }),
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
+      }),
+      http.get('/api/v1/archives/:id', () => {
+        return HttpResponse.json({ id: 1, sliced_for_model: null });
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json({ is_multi_plate: false, plates: [] });
+      }),
+      http.get('/api/v1/archives/:id/filament-requirements', () => {
+        return HttpResponse.json({ filaments: [] });
+      }),
+      http.get('/api/v1/finance/cost-centers/mine', () => {
+        return HttpResponse.json([
+          {
+            id: 42,
+            name: 'Lab',
+            is_private: false,
+            owner_user_id: null,
+            is_active: true,
+            total_balance: 0,
+            total_budget: 100,
+            monthly_budget: 100,
+            budget_mode: 'monthly',
+            budget_limit: 100,
+            budget_used: 0,
+            budget_available: 88,
+            can_print: true,
+          },
+        ]);
+      }),
+      http.patch('/api/v1/queue/:id', async ({ request }) => {
+        const body = await request.json() as Record<string, unknown>;
+        expect(body.cost_center_id).toBe(42);
+        expect(body.estimated_cost).toBe(12.34);
+        return HttpResponse.json({ id: 9, status: 'pending' });
+      })
+    );
+  });
+
+  it('includes the selected cost center and estimate when saving an edit-queue item', async () => {
+    const user = userEvent.setup();
+
+    renderWithProviders(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Billing Print"
+        queueItem={mockQueueItem as never}
+        onClose={vi.fn()}
+        onSuccess={vi.fn()}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Lab')).not.toBeNull();
+    });
+
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => {
+      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();
+  });
+});

+ 55 - 1
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -6,7 +6,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, waitFor, act } from '@testing-library/react';
+import { renderHook, waitFor, act, screen } from '@testing-library/react';
 import React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ToastProvider } from '../../contexts/ToastContext';
@@ -23,6 +23,14 @@ vi.mock('react-i18next', () => ({
         const { printer, slots } = options as { printer: string; slots: string };
         return `Missing assignments for ${printer}: ${slots}`;
       }
+      if (key === 'printers.toast.killSwitchTriggered' && options) {
+        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: {},
@@ -490,6 +498,52 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
     });
 
+    it('shows an error toast when the billing kill switch stops a print', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+        ws.simulateMessage({
+          type: 'kill_switch_triggered',
+          printer_id: 7,
+          printer_name: 'Printer B',
+          filename: 'foreign_job.3mf',
+        });
+      });
+
+      const toast = screen.getByText('Billing kill switch stopped foreign_job.3mf on Printer B');
+      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 - 2
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -368,7 +368,7 @@ describe('SettingsPage', () => {
 
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
-        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']),
+        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'finance', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']),
       );
     });
 
@@ -410,7 +410,7 @@ describe('SettingsPage', () => {
       expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
-        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']),
+        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'finance', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']),
       );
 
       const settingsRow = screen.getAllByText('Settings')
@@ -476,6 +476,7 @@ describe('SettingsPage', () => {
           'archives',
           'queue',
           'projects',
+          'finance',
           'files',
           'makerworld',
           'profiles',

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

@@ -1328,6 +1328,11 @@ export interface AppSettings {
   // Staggered batch start defaults
   stagger_group_size: number;
   stagger_interval_minutes: number;
+  // Finance budget reset window
+  billing_enabled: boolean;
+  printer_kill_switch_enabled: boolean;
+  finance_budget_reset_day: number;
+  finance_budget_reset_timezone: string;
   // Plate-clear confirmation
   require_plate_clear: boolean;
   // Shortest job first scheduling
@@ -2317,6 +2322,8 @@ export interface PrintQueueItem {
   // Either archive_id OR library_file_id must be set (archive created at print start)
   archive_id: number | null;
   library_file_id: number | null;
+  cost_center_id: number | null;
+  estimated_cost: number | null;
   position: number;
   scheduled_time: string | null;
   require_previous_success: boolean;
@@ -2474,6 +2481,8 @@ export interface PrintQueueItemCreate {
   batch_id?: number | null;
   // Project to associate the resulting archive with
   project_id?: number;
+  cost_center_id?: number | null;
+  estimated_cost?: number | null;
   // Delete transient uploaded library file after scheduler creates the archive
   cleanup_library_after_dispatch?: boolean;
   // Cross-model alternatives (#671): several sliced files, one job, whichever
@@ -2550,6 +2559,8 @@ export interface PrintQueueItemUpdate {
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
+  cost_center_id?: number | null;
+  estimated_cost?: number | null;
 }
 
 export interface PrintQueueBulkUpdate {
@@ -2571,6 +2582,8 @@ export interface PrintQueueBulkUpdate {
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
+  cost_center_id?: number | null;
+  estimated_cost?: number | null;
 }
 
 export interface PrintQueueBulkUpdateResponse {
@@ -2688,6 +2701,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;
@@ -2749,6 +2763,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;
@@ -2803,6 +2818,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;
@@ -3586,6 +3602,114 @@ export interface ExternalLinkUpdate {
   open_in_new_tab?: boolean;
 }
 
+// Finance types
+export interface CostCenterSummary {
+  id: number;
+  name: string;
+  is_private: boolean;
+  owner_user_id: number | null;
+  is_active: boolean;
+  total_balance: number;
+  total_budget: number | null;
+  monthly_budget: number | null;
+  budget_mode: 'none' | 'total' | 'monthly';
+  budget_limit: number | null;
+  budget_used: number | null;
+  budget_available: number | null;
+  can_print: boolean;
+}
+
+export interface CostCenterCreateRequest {
+  name: string;
+  total_budget?: number | null;
+  monthly_budget?: number | null;
+  is_active?: boolean;
+}
+
+export interface CostCenterBudgetUpdateRequest {
+  total_budget?: number | null;
+  monthly_budget?: number | null;
+}
+
+export interface CostCenterUpdateRequest {
+  name?: string;
+  is_active?: boolean;
+}
+
+export interface CostCenterMemberRequest {
+  user_id: number;
+  can_print?: boolean;
+}
+
+export interface CostCenterMemberResponse {
+  id: number;
+  cost_center_id: number;
+  user_id: number;
+  can_print: boolean;
+  created_at: string;
+}
+
+export interface CostCenterDetail extends CostCenterSummary {
+  members: CostCenterMemberResponse[];
+}
+
+export interface WalletBalance {
+  user_id: number;
+  balance: number;
+  currency: string;
+  updated_at: string | null;
+}
+
+export type WalletTransactionType = 'print_charge' | 'deposit' | 'withdraw' | 'manual_adjustment';
+
+export interface WalletTransaction {
+  id: number;
+  user_id: number;
+  cost_center_id: number | null;
+  transaction_type: WalletTransactionType;
+  amount: number;
+  balance_after: number | null;
+  description: string | null;
+  created_by_user_id: number | null;
+  print_run_id: string | null;
+  print_archive_id: number | null;
+  print_queue_id: number | null;
+  created_at: string;
+}
+
+export interface WalletTransactionListResponse {
+  items: WalletTransaction[];
+  total: number;
+  limit: number;
+  offset: number;
+}
+
+export interface WalletAdjustmentRequest {
+  amount: number;
+  description?: string;
+  cost_center_id?: number | null;
+}
+
+export interface WalletAdjustmentResponse {
+  transaction: WalletTransaction;
+  balance: WalletBalance;
+}
+
+export interface TransactionEditRequest {
+  user_id?: number | null;
+  cost_center_id?: number | null;
+  amount?: number | null;
+  description?: string | null;
+}
+
+export interface ManualPrintRequest {
+  user_id: number;
+  cost_center_id: number;
+  amount: number;
+  description?: string | null;
+  created_at?: string | null;
+}
+
 // Permission type - all available permissions
 export type Permission =
   | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'
@@ -3612,6 +3736,7 @@ export type Permission =
   | 'discovery:scan'
   | 'firmware:read' | 'firmware:update'
   | 'ams_history:read'
+  | 'cost_centers:read_own' | 'cost_centers:read_all' | 'cost_centers:modify' | 'cost_centers:create'
   | 'stats:read' | 'stats:filter_by_user'
   | 'system:read'
   | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
@@ -5497,6 +5622,79 @@ export const api = {
       body: JSON.stringify(profiles),
     }),
 
+  // Finance
+  getMyBalance: () => request<WalletBalance>('/finance/me/balance'),
+  getMyTransactions: (limit = 50, offset = 0) =>
+    request<WalletTransactionListResponse>(`/finance/me/transactions?limit=${limit}&offset=${offset}`),
+  getAllTransactions: (limit = 50, offset = 0, userId?: number) => {
+    const params = new URLSearchParams();
+    params.set('limit', String(limit));
+    params.set('offset', String(offset));
+    if (userId !== undefined) params.set('user_id', String(userId));
+    return request<WalletTransactionListResponse>(`/finance/transactions?${params.toString()}`);
+  },
+  deleteTransaction: (transactionId: number) =>
+    request<{ status: string }>(`/finance/transactions/${transactionId}`, {
+      method: 'DELETE',
+    }),
+  editTransaction: (transactionId: number, data: TransactionEditRequest) =>
+    request<WalletTransaction>(`/finance/transactions/${transactionId}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  createManualPrint: (data: ManualPrintRequest) =>
+    request<WalletTransaction>('/finance/transactions/manual', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  getMyCostCenters: () => request<CostCenterSummary[]>('/finance/cost-centers/mine'),
+  listCostCenters: (includeInactive = false) =>
+    request<CostCenterSummary[]>(`/finance/cost-centers?include_inactive=${includeInactive ? 'true' : 'false'}`),
+  createCostCenter: (data: CostCenterCreateRequest) =>
+    request<CostCenterSummary>('/finance/cost-centers', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateCostCenter: (costCenterId: number, data: CostCenterUpdateRequest) =>
+    request<CostCenterSummary>(`/finance/cost-centers/${costCenterId}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  updateCostCenterBudgets: (costCenterId: number, data: CostCenterBudgetUpdateRequest) =>
+    request<CostCenterSummary>(`/finance/cost-centers/${costCenterId}/budgets`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteCostCenter: (costCenterId: number) =>
+    request<{ status: string }>(`/finance/cost-centers/${costCenterId}`, {
+      method: 'DELETE',
+    }),
+  getCostCenter: (costCenterId: number) =>
+    request<CostCenterDetail>(`/finance/cost-centers/${costCenterId}`),
+  upsertCostCenterMember: (costCenterId: number, data: CostCenterMemberRequest) =>
+    request<CostCenterMemberResponse>(`/finance/cost-centers/${costCenterId}/members`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  removeCostCenterMember: (costCenterId: number, userId: number) =>
+    request<{ status: string }>(`/finance/cost-centers/${costCenterId}/members/${userId}`, {
+      method: 'DELETE',
+    }),
+  depositUserBalance: (userId: number, data: WalletAdjustmentRequest) =>
+    request<WalletAdjustmentResponse>(`/finance/users/${userId}/deposit`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  withdrawUserBalance: (userId: number, data: WalletAdjustmentRequest) =>
+    request<WalletAdjustmentResponse>(`/finance/users/${userId}/withdraw`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  rebuildBalanceLedger: () =>
+    request<{ status: string }>('/finance/rebuild-balance-ledger', {
+      method: 'POST',
+    }),
+
   // K-Profile Notes (stored locally, not on printer)
   getKProfileNotes: (printerId: number) =>
     request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),

+ 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') });

+ 3 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Wallet, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -43,6 +43,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
+  { id: 'finance', to: '/finance', icon: Wallet, labelKey: 'nav.finance' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
@@ -309,6 +310,7 @@ export function Layout() {
       maintenance: 'maintenance:read',
       projects: 'projects:read',
       inventory: 'inventory:read',
+      finance: 'cost_centers:read_own',
       files: ['library:read', 'library:read_own', 'library:read_all'],
       makerworld: 'makerworld:view',
       settings: 'settings:read',

+ 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>

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

@@ -0,0 +1,44 @@
+import { useTranslation } from 'react-i18next';
+import type { CostCenterSummary } from '../../api/client';
+
+interface CostCenterSelectProps {
+  costCenters: CostCenterSummary[];
+  selectedCostCenterId: number | null;
+  onChange: (costCenterId: number | null) => void;
+}
+
+export function CostCenterSelect({
+  costCenters,
+  selectedCostCenterId,
+  onChange,
+}: CostCenterSelectProps) {
+  const { t } = useTranslation();
+
+  if (costCenters.length === 0) return null;
+  const selectedCostCenter = costCenters.find((center) => center.id === selectedCostCenterId);
+
+  return (
+    <div className="space-y-1">
+      <label htmlFor="printCostCenter" className="text-sm text-bambu-gray">
+        {t('printModal.costCenter', 'Cost center')}
+      </label>
+      <select
+        id="printCostCenter"
+        value={selectedCostCenterId ?? ''}
+        onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
+        className="w-full px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
+      >
+        {costCenters.map((center) => (
+          <option key={center.id} value={center.id}>
+            {center.name}{center.is_private ? ` (${t('printModal.personalDefault', 'Personal')})` : ''}
+          </option>
+        ))}
+      </select>
+      {selectedCostCenter?.budget_mode === 'none' && (
+        <p className="text-xs text-bambu-gray">
+          {t('printModal.unlimitedNoBudget', 'Unlimited – no budget limit is set.')}
+        </p>
+      )}
+    </div>
+  );
+}

+ 29 - 0
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -18,6 +18,9 @@ export function FilamentMapping({
   filamentReqs,
   manualMappings,
   onManualMappingChange,
+  onEstimatedCostChange,
+  budgetAvailable,
+  quantity = 1,
   currencySymbol,
   defaultCostPerKg,
   defaultExpanded = false,
@@ -170,10 +173,24 @@ export function FilamentMapping({
     return total;
   }, [filamentComparison, trayCostMap, defaultCostPerKg]);
 
+  // Callers rendering one mapping per selected plate naturally create a
+  // plate-scoped callback inline. Keep the latest callback in a ref so a new
+  // function identity does not retrigger the cost effect and create a
+  // parent/child render loop.
+  const onEstimatedCostChangeRef = useRef(onEstimatedCostChange);
+  useEffect(() => {
+    onEstimatedCostChangeRef.current = onEstimatedCostChange;
+  }, [onEstimatedCostChange]);
+  useEffect(() => {
+    onEstimatedCostChangeRef.current?.(totalCost > 0 ? totalCost : null);
+  }, [totalCost]);
+
   const hasAnyCost = useMemo(
     () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),
     [trayCostMap]
   );
+  const budgetCheckCost = totalCost * Math.max(1, quantity);
+  const isBudgetInsufficient = budgetAvailable != null && budgetCheckCost > budgetAvailable;
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
   const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
 
@@ -416,7 +433,19 @@ export function FilamentMapping({
             <span className="text-white">
               {totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
             </span>
+            {quantity > 1 && totalCost > 0 && (
+              <span className="ml-2">
+                {t('printModal.totalCostForQuantity', 'total: {{cost}}', {
+                  cost: `${currencySymbol}${budgetCheckCost.toFixed(2)}`,
+                })}
+              </span>
+            )}
           </div>
+          {isBudgetInsufficient && (
+            <p className="text-xs text-red-400 mt-2">
+              {t('printModal.insufficientBudget', 'Insufficient budget for this cost center.')}
+            </p>
+          )}
           {hasTypeMismatch && (
             <p className="text-xs text-orange-700 dark:text-orange-400 mt-2">Required filament type not found in printer.</p>
           )}

+ 90 - 2
frontend/src/components/PrintModal/index.tsx

@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
 import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import type { PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
+import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
 import { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
 import { Card, CardContent } from '../Card';
@@ -30,6 +30,7 @@ import { PrinterSelector } from './PrinterSelector';
 import { PrintOptionsPanel } from './PrintOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
 import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
+import { CostCenterSelect } from './CostCenterSelect';
 import type {
   AssignmentMode,
   FilamentReqsData,
@@ -64,7 +65,7 @@ export function PrintModal({
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, user } = useAuth();
 
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
@@ -221,6 +222,12 @@ export function PrintModal({
     return null;
   });
 
+  const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() =>
+    mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : null
+  );
+  const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
+  const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState<Record<number, number | null>>({});
+
   // Filament overrides for model-based assignment: slot_id -> {type, color}
   const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
     if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
@@ -301,12 +308,37 @@ export function PrintModal({
 
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
+  const billingEnabled = settings?.billing_enabled === true;
 
   const { data: printers, isLoading: loadingPrinters } = useQuery({
     queryKey: ['printers'],
     queryFn: api.getPrinters,
   });
 
+  const { data: myCostCenters, isLoading: loadingCostCenters } = useQuery({
+    queryKey: ['finance', 'cost-centers', 'mine'],
+    queryFn: api.getMyCostCenters,
+    enabled: !!user && billingEnabled,
+  });
+
+  const printableCostCenters = useMemo(
+    () => (myCostCenters || []).filter((center: CostCenterSummary) => center.can_print && center.is_active),
+    [myCostCenters],
+  );
+  const selectedCostCenter = useMemo(
+    () => printableCostCenters.find((center) => center.id === selectedCostCenterId) ?? null,
+    [printableCostCenters, selectedCostCenterId],
+  );
+
+  useEffect(() => {
+    if (printableCostCenters.length === 0) return;
+    if (selectedCostCenterId != null && printableCostCenters.some((center) => center.id === selectedCostCenterId)) {
+      return;
+    }
+    const preferredPrivate = printableCostCenters.find((center) => center.is_private);
+    setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id);
+  }, [printableCostCenters, selectedCostCenterId]);
+
   const { data: spoolAssignments } = useQuery({
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
@@ -792,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 &&
@@ -1050,6 +1087,8 @@ export function PrintModal({
     // Common queue data for create and edit modes
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
       const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
+      const plateEstimatedCost =
+        plateId != null && isMultiPlateSelection ? estimatedCostsByPlate[plateId] ?? null : estimatedCost;
       return {
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
@@ -1073,6 +1112,8 @@ export function PrintModal({
         : undefined,
       ...printOptions,
       project_id: projectId ?? undefined,
+      cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+      estimated_cost: billingEnabled && selectedCostCenterId != null ? plateEstimatedCost : undefined,
       batch_id: autoBatchId ?? undefined,
       cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
       };
@@ -1104,6 +1145,8 @@ export function PrintModal({
                 ? new Date(scheduleOptions.scheduledTime).toISOString()
                 : null,
               ...printOptions,
+              cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+              estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined,
             };
             await updateQueueMutation.mutateAsync(updateData);
           } else {
@@ -1160,6 +1203,12 @@ export function PrintModal({
                   ? new Date(scheduleOptions.scheduledTime).toISOString()
                   : null,
                 ...printOptions,
+                cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+                estimated_cost: billingEnabled && selectedCostCenterId != null
+                  ? (plateId != null && isMultiPlateSelection
+                    ? estimatedCostsByPlate[plateId] ?? null
+                    : estimatedCost)
+                  : undefined,
               };
               await updateQueueMutation.mutateAsync(updateData);
             } else {
@@ -1233,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
@@ -1269,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)
@@ -1340,6 +1397,12 @@ export function PrintModal({
     isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
   );
 
+  useEffect(() => {
+    if (!showFilamentMapping || archiveDataMissing || selectedPrinters.length !== 1) {
+      setEstimatedCost(null);
+    }
+  }, [archiveDataMissing, selectedPrinters.length, showFilamentMapping]);
+
   // Several plates on one printer: one mapping panel per plate, each mapping only
   // the slots its own plate prints. Multi-printer fan-out would be a panel per
   // plate *per printer*, so those items ship without a mapping and the scheduler
@@ -1600,6 +1663,9 @@ export function PrintModal({
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
+                onEstimatedCostChange={setEstimatedCost}
+                budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
+                quantity={effectiveQuantity}
                 defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
@@ -1627,6 +1693,11 @@ export function PrintModal({
                   onManualMappingChange={(mappings) =>
                     setManualMappingsByPlate((prev) => ({ ...prev, [plateId]: mappings }))
                   }
+                  onEstimatedCostChange={(cost) =>
+                    setEstimatedCostsByPlate((prev) => ({ ...prev, [plateId]: cost }))
+                  }
+                  budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
+                  quantity={quantityForPlate(plateId)}
                   defaultExpanded={false}
                   currencySymbol={currencySymbol}
                   defaultCostPerKg={defaultCostPerKg}
@@ -1649,6 +1720,23 @@ export function PrintModal({
               />
             )}
 
+            {billingEnabled && printableCostCenters.length > 0 && (
+              <CostCenterSelect
+                costCenters={printableCostCenters}
+                selectedCostCenterId={selectedCostCenterId}
+                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
                 in PlateSelector own the number instead (#342). */}

+ 3 - 0
frontend/src/components/PrintModal/types.ts

@@ -234,6 +234,9 @@ export interface FilamentMappingProps {
   filamentReqs: FilamentReqsData | undefined;
   manualMappings: Record<number, number>;
   onManualMappingChange: (mappings: Record<number, number>) => void;
+  onEstimatedCostChange?: (estimatedCost: number | null) => void;
+  budgetAvailable?: number | null;
+  quantity?: number;
   currencySymbol: string;
   defaultCostPerKg: number;
   /** Per-slot force-color-match flags. The scheduler honors this flag in both

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

@@ -17,6 +17,7 @@ interface WebSocketMessage {
   printer_id?: number;
   data?: Record<string, unknown>;
   printer_name?: string;
+  filename?: string;
   missing_slots?: Array<{ slot?: string }>;
   // Spool-assignment read-back verification (#2582).
   slot?: string;
@@ -338,6 +339,20 @@ export function useWebSocket() {
         debouncedInvalidate('archiveStats');
         break;
 
+      case 'kill_switch_triggered': {
+        const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
+        const filename = message.filename || t('common.unknown');
+        showToast(t('printers.toast.killSwitchTriggered', { printer, filename }), 'error');
+        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');

+ 116 - 1
frontend/src/i18n/locales/de.ts

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profile',
     maintenance: 'Wartung',
     projects: 'Projekte',
+    finance: 'Finanzen',
     inventory: 'Filament',
     files: 'Dateimanager',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: 'Aktionen',
     status: 'Status',
     name: 'Name',
+    user: 'Benutzer',
     description: 'Beschreibung',
     date: 'Datum',
     time: 'Zeit',
@@ -124,6 +126,97 @@ export default {
     duplicate: 'Duplizieren',
     left: 'Links',
     right: 'Rechts',
+    run: 'Ausführen',
+    running: 'Wird ausgeführt...',
+  },
+
+  finance: {
+    allTypes: 'Alle Typen',
+    allCostCenters: 'Alle Kostenstellen',
+    title: 'Finanzen',
+    subtitle: 'Wallet, persönliche Transaktionen und Kostenstellen',
+    noAccess: 'Du hast keine Berechtigung, Finanzdaten anzuzeigen.',
+    personalView: 'Persönliche Ansicht',
+    adminView: 'Admin-Ansicht',
+    createCostCenter: 'Kostenstelle erstellen',
+    adjustWallet: 'Wallet anpassen',
+    addManualPrint: 'Manuellen Druck nachtragen',
+    manageMembers: 'Mitglieder der Kostenstelle verwalten',
+    currentBalance: 'Persönlicher Kontostand',
+    transactions: 'Transaktionen',
+    personalTransactions: 'Persönliche Transaktionen',
+    costCenters: 'Kostenstellen',
+    availableForPrinting: 'Für Druckzuordnung verfügbar',
+    costCenterName: 'Name',
+    budgetType: 'Budgettyp',
+    monthlyBudget: 'Monatsbudget',
+    totalBudget: 'Gesamtbudget',
+    noBudget: "kein Budget gesetzt",
+    create: 'Erstellen',
+    selectUser: 'Benutzer auswählen',
+    transactionType: 'Typ',
+    amount: 'Betrag',
+    noCostCenter: 'Keine Kostenstelle',
+    descriptionOptional: 'Beschreibung (optional)',
+    applyAdjustment: 'Anpassung anwenden',
+    memberCanPrint: 'Mitglied darf drucken',
+    addMember: 'Mitglied hinzufügen',
+    canPrint: 'Darf drucken',
+    noMembers: 'Keine Mitglieder zugewiesen.',
+    editCostCenter: 'Kostenstelle bearbeiten',
+    myCostCenters: 'Meine Kostenstellen',
+    costCentersHint: 'Budgetgrenzen prüfen und Kosten im Blick behalten',
+    noCostCenters: 'Keine Kostenstellen gefunden.',
+    owner: 'Besitzer',
+    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',
+    recentTransactions: 'Aktuelle Transaktionen',
+    transactionsHint: 'Nach Typ und Kostenstelle filtern',
+    first: 'Erste',
+    prev: 'Zurück',
+    next: 'Weiter',
+    last: 'Letzte',
+    pageNumberOf: 'Seite {{page}} von {{total}}',
+    noTransactions: 'Keine Transaktionen verfügbar.',
+    noTransactionsForFilter: 'Keine Transaktionen entsprechen den ausgewählten Filtern.',
+    costCenter: 'Kostenstelle',
+    balanceAfter: 'Kontostand danach',
+    userWithId: 'Benutzer #{{id}}',
+    partial: 'Teilweise',
+    editTransaction: 'Transaktion bearbeiten',
+    selectCostCenter: 'Kostenstelle auswählen...',
+    costCenterRequired: 'Bitte eine Kostenstelle auswählen',
+    amountExample: 'z. B. 4,00',
+    manualAdjustmentExample: 'z. B. Manuelle Anpassung',
+    userRequired: 'Bitte einen Benutzer auswählen',
+    amountInvalid: 'Betrag muss eine gültige Zahl sein',
+    createdCostCenter: 'Kostenstelle erstellt',
+    createCostCenterFailed: 'Kostenstelle konnte nicht erstellt werden',
+    transactionDeleted: 'Transaktion gelöscht',
+    deleteTransactionFailed: 'Transaktion konnte nicht gelöscht werden',
+    transactionEdited: 'Transaktion aktualisiert und Ledger neu berechnet',
+    editTransactionFailed: 'Transaktion konnte nicht bearbeitet werden',
+    manualPrintCreated: 'Manueller Druck hinzugefügt und Ledger neu berechnet',
+    manualPrintFailed: 'Manueller Druck konnte nicht erstellt werden',
+    memberSaved: 'Mitglied gespeichert',
+    memberSaveFailed: 'Mitglied konnte nicht gespeichert werden',
+    memberRemoved: 'Mitglied entfernt',
+    memberRemoveFailed: 'Mitglied konnte nicht entfernt werden',
+    costCenterNameRequired: 'Kostenstellenname ist erforderlich',
+    costCenterUpdated: 'Kostenstelle aktualisiert',
+    costCenterUpdateFailed: 'Kostenstelle konnte nicht aktualisiert werden',
+    confirmDeleteCostCenter: 'Kostenstelle "{{name}}" löschen?',
+    costCenterDeleted: 'Kostenstelle gelöscht',
+    costCenterDeleteFailed: 'Kostenstelle konnte nicht gelöscht werden',
+    deleteTransactionConfirm: 'Diese Transaktion löschen? Die Salden werden automatisch neu berechnet.',
+    deposit: 'Einzahlung',
+    withdraw: 'Auszahlung',
+    printCharge: 'Druckkosten',
+    deleteTransaction: 'Transaktion löschen',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       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',
@@ -2294,6 +2389,21 @@ export default {
     },
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
+    billingEnabled: 'Abrechnung aktivieren',
+    billingEnabledDescription: 'Kostenstellen aktivieren und Drucke verbuchen',
+    printerKillSwitch: 'Automatischer Druckstopp',
+    printerKillSwitchDescription: 'Drucke ohne Authorisierung sofort abbrechen',
+    financeBudgetReset: 'Finanzbudget monatlich zurücksetzen',
+    financeBudgetResetDay: 'Reset-Tag',
+    financeBudgetResetDayHelp: 'Für kurze Monate verwendet der Reset den letzten Tag des Monats.',
+    financeBudgetResetTimezone: 'Reset-Zeitzone',
+    financeBudgetResetTimezoneHelp: 'Der Budget-Fenster-Start wird in dieser Zeitzone berechnet.',
+    rebuildLedger: 'Ledger wiederaufbauen',
+    rebuildLedgerStarted: 'Ledger-Wiederaufbau gestartet',
+    rebuildLedgerConfirmTitle: 'Ledger wiederaufbauen?',
+    rebuildLedgerConfirmMessage:
+      'Dies baut das Wallet-Ledger neu auf, um historische Saldenwerte zu reparieren. Führen Sie dies nur aus, wenn Sie genau wissen, was Sie tun.',
+    rebuildLedgerInProgress: 'Wiederaufbau wird gestartet...',
     printsOnly: 'Nur Drucke',
     totalConsumption: 'Gesamtverbrauch',
     dataManagement: 'Datenverwaltung',
@@ -3992,7 +4102,7 @@ export default {
       title: 'Kostenverfolgung',
       filamentCost: 'Filamentkosten',
       energy: 'Energie',
-      totalCost: 'Gesamtkosten',
+      totalCost: 'Erwartete Gesamtkosten',
       total: 'Gesamt',
       includesBom: 'inkl. Stückliste',
       budget: 'Budget',
@@ -4799,6 +4909,9 @@ export default {
     staggerTotal: 'insgesamt: {{minutes}} Min.',
     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
@@ -5791,6 +5904,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',

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

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     projects: 'Projects',
+    finance: 'Finance',
     inventory: 'Filament',
     files: 'File Manager',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: 'Actions',
     status: 'Status',
     name: 'Name',
+    user: 'User',
     description: 'Description',
     date: 'Date',
     time: 'Time',
@@ -124,6 +126,97 @@ export default {
     duplicate: 'Duplicate',
     left: 'Left',
     right: 'Right',
+    run: 'Run',
+    running: 'Running...',
+  },
+
+  finance: {
+    allTypes: 'All types',
+    allCostCenters: 'All cost centers',
+    title: 'Finance',
+    subtitle: 'Wallet, personal transactions, and cost centers',
+    noAccess: 'You do not have permission to view finance data.',
+    personalView: 'Personal view',
+    adminView: 'Admin view',
+    createCostCenter: 'Create cost center',
+    adjustWallet: 'Adjust wallet',
+    addManualPrint: 'Add manual print',
+    manageMembers: 'Manage cost center members',
+    currentBalance: 'Personal balance',
+    transactions: 'Transactions',
+    personalTransactions: 'Personal transactions',
+    costCenters: 'Cost centers',
+    availableForPrinting: 'Available for print assignment',
+    costCenterName: 'Name',
+    budgetType: 'Budget type',
+    monthlyBudget: 'Monthly budget',
+    totalBudget: 'Total budget',
+    noBudget: "No budget",
+    create: 'Create',
+    selectUser: 'Select user',
+    transactionType: 'Type',
+    amount: 'Amount',
+    noCostCenter: 'No cost center',
+    descriptionOptional: 'Description (optional)',
+    applyAdjustment: 'Apply adjustment',
+    memberCanPrint: 'Member can print',
+    addMember: 'Add member',
+    canPrint: 'Can print',
+    noMembers: 'No members assigned.',
+    editCostCenter: 'Edit cost center',
+    myCostCenters: 'My cost centers',
+    costCentersHint: 'Review budget limits and keep costs under control',
+    noCostCenters: 'No cost centers found.',
+    owner: 'Owner',
+    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',
+    recentTransactions: 'Recent transactions',
+    transactionsHint: 'Filter by type and cost center',
+    first: 'First',
+    prev: 'Previous',
+    next: 'Next',
+    last: 'Last',
+    pageNumberOf: 'Page {{page}} of {{total}}',
+    noTransactions: 'No transactions available.',
+    noTransactionsForFilter: 'No transactions match the selected filters.',
+    costCenter: 'Cost center',
+    balanceAfter: 'Balance after',
+    userWithId: 'User #{{id}}',
+    partial: 'Partial',
+    editTransaction: 'Edit Transaction',
+    selectCostCenter: 'Select cost center...',
+    costCenterRequired: 'Please select a cost center',
+    amountExample: 'e.g., 4.00',
+    manualAdjustmentExample: 'e.g., Manual adjustment',
+    userRequired: 'Please select a user',
+    amountInvalid: 'Amount must be a valid number',
+    createdCostCenter: 'Cost center created',
+    createCostCenterFailed: 'Failed to create cost center',
+    transactionDeleted: 'Transaction deleted',
+    deleteTransactionFailed: 'Failed to delete transaction',
+    transactionEdited: 'Transaction updated and ledger recalculated',
+    editTransactionFailed: 'Failed to edit transaction',
+    manualPrintCreated: 'Manual print charge added and ledger recalculated',
+    manualPrintFailed: 'Failed to create manual print',
+    memberSaved: 'Member saved',
+    memberSaveFailed: 'Failed to save member',
+    memberRemoved: 'Member removed',
+    memberRemoveFailed: 'Failed to remove member',
+    costCenterNameRequired: 'Cost center name is required',
+    costCenterUpdated: 'Cost center updated',
+    costCenterUpdateFailed: 'Failed to update cost center',
+    confirmDeleteCostCenter: 'Delete cost center "{{name}}"?',
+    costCenterDeleted: 'Cost center deleted',
+    costCenterDeleteFailed: 'Failed to delete cost center',
+    deleteTransactionConfirm: 'Delete this transaction? Balances will be recalculated automatically.',
+    deposit: 'Deposit',
+    withdraw: 'Withdraw',
+    printCharge: 'Print charge',
+    deleteTransaction: 'Delete transaction',
   },
 
   // Printers page
@@ -359,6 +452,8 @@ export default {
     toast: {
       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',
@@ -2313,6 +2408,21 @@ export default {
     },
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
+    billingEnabled: 'Enable Billing',
+    billingEnabledDescription: 'Charge users for prints and enable finance features',
+    printerKillSwitch: 'Unauthorized print kill switch',
+    printerKillSwitchDescription: 'Immediately stop prints that start without authorization.',
+    financeBudgetReset: 'Finance Monthly Budget Reset',
+    financeBudgetResetDay: 'Reset Day',
+    financeBudgetResetDayHelp: 'For short months, reset uses the last day of the month.',
+    financeBudgetResetTimezone: 'Reset timezone',
+    financeBudgetResetTimezoneHelp: 'Budget window start is calculated in this timezone.")',
+    rebuildLedger: 'Rebuild wallet ledger',
+    rebuildLedgerStarted: 'Ledger rebuild started',
+    rebuildLedgerConfirmTitle: 'Rebuild wallet ledger?',
+    rebuildLedgerConfirmMessage:
+      'This will rebuild the wallet ledger to repair historical balance values. Run this only if you know what you are doing.',
+    rebuildLedgerInProgress: 'Starting rebuild...',
     printsOnly: 'Prints Only',
     totalConsumption: 'Total Consumption',
     dataManagement: 'Data Management',
@@ -4021,7 +4131,7 @@ export default {
       title: 'Cost Tracking',
       filamentCost: 'Filament Cost',
       energy: 'Energy',
-      totalCost: 'Total Cost',
+      totalCost: 'Estimated Cost',
       total: 'Total',
       includesBom: 'incl. BOM',
       budget: 'Budget',
@@ -4842,6 +4952,9 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     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
@@ -5840,6 +5953,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',

+ 114 - 0
frontend/src/i18n/locales/es.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Mantenimiento',
     projects: 'Proyectos',
     inventory: 'Filamento',
+    finance: 'Finanzas',
     files: 'Gestor de archivos',
     makerworld: 'MakerWorld',
     notifications: 'Notificaciones',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Duplicar',
     left: 'Izquierda',
     right: 'Derecha',
+    user: 'Usuario',
+    run: 'Ejecutar',
+    running: 'Ejecutando...',
+  },
+
+  finance: {
+    allTypes: 'Todos los tipos',
+    allCostCenters: 'Todos los centros de costes',
+    title: 'Finanzas',
+    subtitle: 'Monedero, transacciones personales y centros de costes',
+    noAccess: 'No tienes permiso para ver los datos financieros.',
+    personalView: 'Vista personal',
+    adminView: 'Vista de administración',
+    createCostCenter: 'Crear centro de costes',
+    adjustWallet: 'Ajustar monedero',
+    addManualPrint: 'Añadir impresión manual',
+    manageMembers: 'Gestionar miembros del centro de costes',
+    currentBalance: 'Saldo personal',
+    transactions: 'Transacciones',
+    personalTransactions: 'Transacciones personales',
+    costCenters: 'Centros de costes',
+    availableForPrinting: 'Disponible para asignar impresiones',
+    costCenterName: 'Nombre',
+    budgetType: 'Tipo de presupuesto',
+    monthlyBudget: 'Presupuesto mensual',
+    totalBudget: 'Presupuesto total',
+    noBudget: 'Sin presupuesto',
+    create: 'Crear',
+    selectUser: 'Seleccionar usuario',
+    transactionType: 'Tipo',
+    amount: 'Importe',
+    noCostCenter: 'Sin centro de costes',
+    descriptionOptional: 'Descripción (opcional)',
+    applyAdjustment: 'Aplicar ajuste',
+    memberCanPrint: 'El miembro puede imprimir',
+    addMember: 'Añadir miembro',
+    canPrint: 'Puede imprimir',
+    noMembers: 'No hay miembros asignados.',
+    editCostCenter: 'Editar centro de costes',
+    myCostCenters: 'Mis centros de costes',
+    costCentersHint: 'Revisa los límites presupuestarios y mantén los costes bajo control',
+    noCostCenters: 'No se encontraron centros de costes.',
+    owner: 'Propietario',
+    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í',
+    recentTransactions: 'Transacciones recientes',
+    transactionsHint: 'Filtrar por tipo y centro de costes',
+    first: 'Primera',
+    prev: 'Anterior',
+    next: 'Siguiente',
+    last: 'Última',
+    pageNumberOf: 'Página {{page}} de {{total}}',
+    noTransactions: 'No hay transacciones disponibles.',
+    noTransactionsForFilter: 'Ninguna transacción coincide con los filtros seleccionados.',
+    costCenter: 'Centro de costes',
+    balanceAfter: 'Saldo posterior',
+    userWithId: 'Usuario n.º {{id}}',
+    partial: 'Parcial',
+    editTransaction: 'Editar transacción',
+    selectCostCenter: 'Seleccionar centro de costes...',
+    costCenterRequired: 'Selecciona un centro de costes',
+    amountExample: 'p. ej., 4,00',
+    manualAdjustmentExample: 'p. ej., Ajuste manual',
+    userRequired: 'Selecciona un usuario',
+    amountInvalid: 'El importe debe ser un número válido',
+    createdCostCenter: 'Centro de costes creado',
+    createCostCenterFailed: 'No se pudo crear el centro de costes',
+    transactionDeleted: 'Transacción eliminada',
+    deleteTransactionFailed: 'No se pudo eliminar la transacción',
+    transactionEdited: 'Transacción actualizada y libro mayor recalculado',
+    editTransactionFailed: 'No se pudo editar la transacción',
+    manualPrintCreated: 'Cargo de impresión manual añadido y libro mayor recalculado',
+    manualPrintFailed: 'No se pudo crear la impresión manual',
+    memberSaved: 'Miembro guardado',
+    memberSaveFailed: 'No se pudo guardar el miembro',
+    memberRemoved: 'Miembro eliminado',
+    memberRemoveFailed: 'No se pudo eliminar el miembro',
+    costCenterNameRequired: 'El nombre del centro de costes es obligatorio',
+    costCenterUpdated: 'Centro de costes actualizado',
+    costCenterUpdateFailed: 'No se pudo actualizar el centro de costes',
+    confirmDeleteCostCenter: '¿Eliminar el centro de costes «{{name}}»?',
+    costCenterDeleted: 'Centro de costes eliminado',
+    costCenterDeleteFailed: 'No se pudo eliminar el centro de costes',
+    deleteTransactionConfirm: '¿Eliminar esta transacción? Los saldos se recalcularán automáticamente.',
+    deposit: 'Ingreso',
+    withdraw: 'Retirada',
+    printCharge: 'Cargo de impresión',
+    deleteTransaction: 'Eliminar transacción',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       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',
@@ -2297,6 +2392,20 @@ export default {
     },
     externalCameras: 'Cámaras externas',
     costTracking: 'Seguimiento de costes',
+    billingEnabled: 'Activar facturación',
+    billingEnabledDescription: 'Cobrar las impresiones a los usuarios y activar las funciones financieras',
+    printerKillSwitch: 'Parada de impresiones no autorizadas',
+    printerKillSwitchDescription: 'Detiene inmediatamente las impresiones que comiencen sin autorización.',
+    financeBudgetReset: 'Reinicio mensual del presupuesto financiero',
+    financeBudgetResetDay: 'Día de reinicio',
+    financeBudgetResetDayHelp: 'En los meses cortos, se usa el último día del mes.',
+    financeBudgetResetTimezone: 'Zona horaria del reinicio',
+    financeBudgetResetTimezoneHelp: 'El inicio del período presupuestario se calcula en esta zona horaria.',
+    rebuildLedger: 'Reconstruir libro mayor del monedero',
+    rebuildLedgerStarted: 'Se inició la reconstrucción del libro mayor',
+    rebuildLedgerConfirmTitle: '¿Reconstruir el libro mayor del monedero?',
+    rebuildLedgerConfirmMessage: 'Esto reconstruirá el libro mayor para reparar saldos históricos. Ejecútalo solo si sabes lo que estás haciendo.',
+    rebuildLedgerInProgress: 'Iniciando reconstrucción...',
     printsOnly: 'Solo impresiones',
     totalConsumption: 'Consumo total',
     dataManagement: 'Gestión de datos',
@@ -4807,6 +4916,9 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     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
@@ -5800,6 +5912,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',

+ 115 - 0
frontend/src/i18n/locales/fr.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Maintenance',
     projects: 'Projets',
     inventory: 'Filament',
+    finance: 'Finances',
     files: 'Gestionnaire de fichiers',
     makerworld: 'MakerWorld',
     notifications: 'Notifications',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Dupliquer',
     left: 'Gauche',
     right: 'Droite',
+    user: 'Utilisateur',
+    run: 'Exécuter',
+    running: 'En cours d\'exécution...',
+  },
+
+  finance: {
+    allTypes: 'Tous les types',
+    allCostCenters: 'Tous les centres de coûts',
+    title: 'Finances',
+    subtitle: 'Portefeuille, transactions personnelles et centres de coûts',
+    noAccess: 'Vous n\'avez pas la permission d\'accéder aux données financières.',
+    personalView: 'Vue personnelle',
+    adminView: 'Vue administrateur',
+    createCostCenter: 'Créer un centre de coûts',
+    adjustWallet: 'Ajuster le portefeuille',
+    addManualPrint: 'Ajouter un tirage manuel',
+    manageMembers: 'Gérer les membres du centre de coûts',
+    currentBalance: 'Solde personnel',
+    transactions: 'Opérations',
+    personalTransactions: 'Transactions personnelles',
+    costCenters: 'Centres de coûts',
+    availableForPrinting: 'Disponible pour l\'attribution d\'impression',
+    costCenterName: 'Nom',
+    budgetType: 'Type de budget',
+    monthlyBudget: 'Budget mensuel',
+    totalBudget: 'Budget total',
+    noBudget: 'Aucun budget',
+    create: 'Créer',
+    selectUser: 'Sélectionner un utilisateur',
+    transactionType: 'Type',
+    amount: 'Montant',
+    noCostCenter: 'Aucun centre de coûts',
+    descriptionOptional: 'Description (facultative)',
+    applyAdjustment: 'Appliquer l\'ajustement',
+    memberCanPrint: 'Le membre peut imprimer',
+    addMember: 'Ajouter un membre',
+    canPrint: 'Peut imprimer',
+    noMembers: 'Aucun membre assigné.',
+    editCostCenter: 'Modifier le centre de coûts',
+    myCostCenters: 'Mes centres de coûts',
+    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 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',
+    recentTransactions: 'Transactions récentes',
+    transactionsHint: 'Filtrer par type et centre de coûts',
+    first: 'Première',
+    prev: 'Précédente',
+    next: 'Suivante',
+    last: 'Dernière',
+    pageNumberOf: 'Page {{page}} sur {{total}}',
+    noTransactions: 'Aucune transaction disponible.',
+    noTransactionsForFilter: 'Aucune transaction ne correspond aux filtres sélectionnés.',
+    costCenter: 'Centre de coûts',
+    balanceAfter: 'Solde après',
+    userWithId: 'Utilisateur #{{id}}',
+    partial: 'Partiel',
+    editTransaction: 'Modifier la transaction',
+    selectCostCenter: 'Sélectionner un centre de coûts...',
+    costCenterRequired: 'Veuillez sélectionner un centre de coûts',
+    amountExample: 'p. ex. 4,00',
+    manualAdjustmentExample: 'p. ex. Ajustement manuel',
+    userRequired: 'Veuillez sélectionner un utilisateur',
+    amountInvalid: 'Le montant doit être un nombre valide',
+    createdCostCenter: 'Centre de coûts créé',
+    createCostCenterFailed: 'Impossible de créer le centre de coûts',
+    transactionDeleted: 'Transaction supprimée',
+    deleteTransactionFailed: 'Impossible de supprimer la transaction',
+    transactionEdited: 'Transaction mise à jour et ledger recalculé',
+    editTransactionFailed: 'Impossible de modifier la transaction',
+    manualPrintCreated: 'Frais de tirage manuel ajoutés et ledger recalculé',
+    manualPrintFailed: 'Impossible de créer le tirage manuel',
+    memberSaved: 'Membre enregistré',
+    memberSaveFailed: 'Impossible d\'enregistrer le membre',
+    memberRemoved: 'Membre supprimé',
+    memberRemoveFailed: 'Impossible de supprimer le membre',
+    costCenterNameRequired: 'Le nom du centre de coûts est requis',
+    costCenterUpdated: 'Centre de coûts mis à jour',
+    costCenterUpdateFailed: 'Impossible de mettre à jour le centre de coûts',
+    confirmDeleteCostCenter: 'Supprimer le centre de coûts « {{name}} » ?',
+    costCenterDeleted: 'Centre de coûts supprimé',
+    costCenterDeleteFailed: 'Impossible de supprimer le centre de coûts',
+    deleteTransactionConfirm: 'Supprimer cette transaction ? Les soldes seront recalculés automatiquement.',
+    deposit: 'Dépôt',
+    withdraw: 'Retrait',
+    printCharge: 'Frais d\'impression',
+    deleteTransaction: 'Supprimer la transaction',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       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',
@@ -2268,6 +2363,21 @@ export default {
     useTls: 'Utiliser TLS',
     enableMetricsEndpoint: 'Activer l\'endpoint Metrics',
     availableMetrics: 'Metrics disponibles',
+    billingEnabled: 'Activer la facturation',
+    billingEnabledDescription: 'Facturer les utilisateurs pour les impressions et activer les fonctionnalités financières',
+    printerKillSwitch: 'Interrupteur d\'arrêt automatique',
+    printerKillSwitchDescription: 'Arrêter immédiatement les impressions qui démarrent sans autorisation',
+    financeBudgetReset: 'Réinitialisation mensuelle du budget financier',
+    financeBudgetResetDay: 'Jour de réinitialisation',
+    financeBudgetResetDayHelp: 'Pour les mois courts, la réinitialisation utilise le dernier jour du mois.',
+    financeBudgetResetTimezone: 'Fuseau horaire de réinitialisation',
+    financeBudgetResetTimezoneHelp: 'Le fuseau horaire utilisé pour déterminer le moment de la réinitialisation du budget chaque mois.',
+    rebuildLedger: 'Reconstruire le grand livre du portefeuille',
+    rebuildLedgerStarted: 'Reconstruction du grand livre commencée',
+    rebuildLedgerConfirmTitle: 'Reconstruire le grand livre du portefeuille?',
+    rebuildLedgerConfirmMessage:
+      'Cela reconstruira le grand livre du portefeuille pour réparer les valeurs de solde historiques. Exécutez ceci uniquement si vous savez ce que vous faites.',
+    rebuildLedgerInProgress: 'Reconstruction en cours de démarrage...',
     editUser: 'Modifier l\'utilisateur',
     deleteUserTitle: 'Supprimer l\'utilisateur',
     groupName: 'Nom du groupe',
@@ -4788,6 +4898,9 @@ export default {
     staggerTotal: 'total : {{minutes}} min',
     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
@@ -5781,6 +5894,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',

+ 115 - 0
frontend/src/i18n/locales/it.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Manutenzione',
     projects: 'Progetti',
     inventory: 'Filamento',
+    finance: 'Finanze',
     files: 'File',
     makerworld: 'MakerWorld',
     notifications: 'Notifiche',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Duplica',
     left: 'Sinistra',
     right: 'Destra',
+    user: 'Utente',
+    run: 'Esegui',
+    running: 'In esecuzione...',
+  },
+
+  finance: {
+    allTypes: 'Tutti i tipi',
+    allCostCenters: 'Tutti i centri di costo',
+    title: 'Finanza',
+    subtitle: 'Portafoglio, transazioni personali e centri di costo',
+    noAccess: 'Non hai il permesso di visualizzare i dati finanziari.',
+    personalView: 'Vista personale',
+    adminView: 'Vista amministratore',
+    createCostCenter: 'Crea centro di costo',
+    adjustWallet: 'Regola portafoglio',
+    addManualPrint: 'Aggiungi stampa manuale',
+    manageMembers: 'Gestisci membri del centro di costo',
+    currentBalance: 'Saldo personale',
+    transactions: 'Transazioni',
+    personalTransactions: 'Transazioni personali',
+    costCenters: 'Centri di costo',
+    availableForPrinting: 'Disponibile per l\'assegnazione stampa',
+    costCenterName: 'Nome',
+    budgetType: 'Tipo di budget',
+    monthlyBudget: 'Budget mensile',
+    totalBudget: 'Budget totale',
+    noBudget: 'Nessun budget',
+    create: 'Crea',
+    selectUser: 'Seleziona utente',
+    transactionType: 'Tipo',
+    amount: 'Importo',
+    noCostCenter: 'Nessun centro di costo',
+    descriptionOptional: 'Descrizione (facoltativa)',
+    applyAdjustment: 'Applica rettifica',
+    memberCanPrint: 'Il membro può stampare',
+    addMember: 'Aggiungi membro',
+    canPrint: 'Può stampare',
+    noMembers: 'Nessun membro assegnato.',
+    editCostCenter: 'Modifica centro di costo',
+    myCostCenters: 'I miei centri di costo',
+    costCentersHint: 'Controlla i limiti di budget e mantieni i costi sotto controllo',
+    noCostCenters: 'Nessun centro di costo trovato.',
+    owner: 'Proprietario',
+    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',
+    recentTransactions: 'Transazioni recenti',
+    transactionsHint: 'Filtra per tipo e centro di costo',
+    first: 'Prima',
+    prev: 'Precedente',
+    next: 'Successiva',
+    last: 'Ultima',
+    pageNumberOf: 'Pagina {{page}} di {{total}}',
+    noTransactions: 'Nessuna transazione disponibile.',
+    noTransactionsForFilter: 'Nessuna transazione corrisponde ai filtri selezionati.',
+    costCenter: 'Centro di costo',
+    balanceAfter: 'Saldo successivo',
+    userWithId: 'Utente #{{id}}',
+    partial: 'Parziale',
+    editTransaction: 'Modifica transazione',
+    selectCostCenter: 'Seleziona centro di costo...',
+    costCenterRequired: 'Si prega di selezionare un centro di costo',
+    amountExample: 'es. 4,00',
+    manualAdjustmentExample: 'es. Rettifica manuale',
+    userRequired: 'Si prega di selezionare un utente',
+    amountInvalid: 'L\'importo deve essere un numero valido',
+    createdCostCenter: 'Centro di costo creato',
+    createCostCenterFailed: 'Impossibile creare il centro di costo',
+    transactionDeleted: 'Transazione eliminata',
+    deleteTransactionFailed: 'Impossibile eliminare la transazione',
+    transactionEdited: 'Transazione aggiornata e ledger ricalcolato',
+    editTransactionFailed: 'Impossibile modificare la transazione',
+    manualPrintCreated: 'Addebito stampa manuale aggiunto e ledger ricalcolato',
+    manualPrintFailed: 'Impossibile creare la stampa manuale',
+    memberSaved: 'Membro salvato',
+    memberSaveFailed: 'Impossibile salvare il membro',
+    memberRemoved: 'Membro rimosso',
+    memberRemoveFailed: 'Impossibile rimuovere il membro',
+    costCenterNameRequired: 'Il nome del centro di costo è obbligatorio',
+    costCenterUpdated: 'Centro di costo aggiornato',
+    costCenterUpdateFailed: 'Impossibile aggiornare il centro di costo',
+    confirmDeleteCostCenter: 'Eliminare il centro di costo "{{name}}"?',
+    costCenterDeleted: 'Centro di costo eliminato',
+    costCenterDeleteFailed: 'Impossibile eliminare il centro di costo',
+    deleteTransactionConfirm: 'Eliminare questa transazione? I saldi verranno ricalcolati automaticamente.',
+    deposit: 'Deposito',
+    withdraw: 'Prelievo',
+    printCharge: 'Addebito stampa',
+    deleteTransaction: 'Elimina transazione',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       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',
@@ -2268,6 +2363,21 @@ export default {
     useTls: 'Usa TLS',
     enableMetricsEndpoint: 'Abilita endpoint metriche',
     availableMetrics: 'Metriche disponibili',
+    billingEnabled: 'Abilita fatturazione',
+    billingEnabledDescription: 'Addebita agli utenti le stampe e abilita le funzioni finanziarie',
+    printerKillSwitch: 'Arresto automatico della stampa',
+    printerKillSwitchDescription: 'Interrompi immediatamente le stampe non autorizzate',
+    financeBudgetReset: 'Azzeramento mensile del budget finanziario',
+    financeBudgetResetDay: 'Giorno di azzeramento',
+    financeBudgetResetDayHelp: "Per i mesi brevi, l'azzeramento utilizza l'ultimo giorno del mese.",
+    financeBudgetResetTimezone: 'Fuso orario di azzeramento',
+    financeBudgetResetTimezoneHelp: "L'inizio della finestra di bilancio viene calcolato in questo fuso orario.",
+    rebuildLedger: 'Ricostruisci il ledger del portafoglio',
+    rebuildLedgerStarted: 'Ricostruzione del ledger avviata',
+    rebuildLedgerConfirmTitle: 'Ricostruire il ledger del portafoglio?',
+    rebuildLedgerConfirmMessage:
+      'Questo ricostruirà il ledger del portafoglio per riparare i valori di saldo storici. Eseguire solo se sai cosa stai facendo.',
+    rebuildLedgerInProgress: 'Avvio della ricostruzione...',
     editUser: 'Modifica utente',
     deleteUserTitle: 'Elimina utente',
     groupName: 'Nome gruppo',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: 'totale: {{minutes}} min',
     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
@@ -5780,6 +5893,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',

+ 115 - 0
frontend/src/i18n/locales/ja.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: 'メンテナンス',
     projects: 'プロジェクト',
     inventory: 'フィラメント',
+    finance: 'ファイナンス',
     files: 'ファイル管理',
     makerworld: 'MakerWorld',
     notifications: '通知',
@@ -124,6 +125,98 @@ export default {
     duplicate: '複製',
     left: '左',
     right: '右',
+    user: 'ユーザー',
+    run: '実行',
+    running: '実行中...',
+  },
+
+  finance: {
+    allTypes: 'すべてのタイプ',
+    allCostCenters: 'すべてのコストセンター',
+    title: 'ファイナンス',
+    subtitle: 'ウォレット、個人トランザクション、およびコストセンター',
+    noAccess: '財務データを表示する権限がありません。',
+    personalView: '個人ビュー',
+    adminView: '管理者ビュー',
+    createCostCenter: 'コストセンターを作成',
+    adjustWallet: 'ウォレットを調整',
+    addManualPrint: '手動プリントを追加',
+    manageMembers: 'コストセンターメンバーを管理',
+    currentBalance: '個人残高',
+    transactions: 'トランザクション',
+    personalTransactions: '個人トランザクション',
+    costCenters: 'コストセンター',
+    availableForPrinting: 'プリント割り当てが可能',
+    costCenterName: '名前',
+    budgetType: '予算タイプ',
+    monthlyBudget: '月間予算',
+    totalBudget: '総予算',
+    noBudget: '予算なし',
+    create: '作成',
+    selectUser: 'ユーザーを選択',
+    transactionType: 'タイプ',
+    amount: '金額',
+    noCostCenter: 'コストセンターなし',
+    descriptionOptional: '説明(オプション)',
+    applyAdjustment: '調整を適用',
+    memberCanPrint: 'メンバーはプリント可能',
+    addMember: 'メンバーを追加',
+    canPrint: 'プリント可能',
+    noMembers: '割り当てられたメンバーはありません。',
+    editCostCenter: 'コストセンターを編集',
+    myCostCenters: 'マイコストセンター',
+    costCentersHint: '予算限度額を確認し、コストを管理下に置きます',
+    noCostCenters: 'コストセンターが見つかりません。',
+    owner: '所有者',
+    balance: '口座残高',
+    unlimited: '無制限',
+    budgetPolicyHint: '口座残高はコストの記録に使用されます。印刷は選択したコストセンターの予算によってのみ制限され、予算がなければ無制限です。',
+    budget: '予算',
+    shared: '共有',
+    cannotEditPrivateCostCenter: 'プライベートコストセンターはここで編集できません',
+    recentTransactions: '最近のトランザクション',
+    transactionsHint: 'タイプとコストセンターでフィルタリングしてから、ページを移動します',
+    first: '最初',
+    prev: '前へ',
+    next: '次へ',
+    last: '最後',
+    pageNumberOf: 'ページ {{page}} / {{total}}',
+    noTransactions: '利用可能なトランザクションはありません。',
+    noTransactionsForFilter: '選択したフィルターに一致するトランザクションはありません。',
+    costCenter: 'コストセンター',
+    balanceAfter: '調整後残高',
+    userWithId: 'ユーザー #{{id}}',
+    partial: '部分',
+    editTransaction: 'トランザクションを編集',
+    selectCostCenter: 'コストセンターを選択...',
+    costCenterRequired: 'コストセンターを選択してください',
+    amountExample: '例:4.00',
+    manualAdjustmentExample: '例:手動調整',
+    userRequired: 'ユーザーを選択してください',
+    amountInvalid: '金額は有効な数値である必要があります',
+    createdCostCenter: 'コストセンターが作成されました',
+    createCostCenterFailed: 'コストセンターの作成に失敗しました',
+    transactionDeleted: 'トランザクションが削除されました',
+    deleteTransactionFailed: 'トランザクションの削除に失敗しました',
+    transactionEdited: 'トランザクションが更新され、レジャーが再計算されました',
+    editTransactionFailed: 'トランザクションの編集に失敗しました',
+    manualPrintCreated: '手動プリント料金が追加され、レジャーが再計算されました',
+    manualPrintFailed: '手動プリントの作成に失敗しました',
+    memberSaved: 'メンバーが保存されました',
+    memberSaveFailed: 'メンバーの保存に失敗しました',
+    memberRemoved: 'メンバーが削除されました',
+    memberRemoveFailed: 'メンバーの削除に失敗しました',
+    costCenterNameRequired: 'コストセンター名は必須です',
+    costCenterUpdated: 'コストセンターが更新されました',
+    costCenterUpdateFailed: 'コストセンターの更新に失敗しました',
+    confirmDeleteCostCenter: 'コストセンター "{{name}}" を削除しますか?',
+    costCenterDeleted: 'コストセンターが削除されました',
+    costCenterDeleteFailed: 'コストセンターの削除に失敗しました',
+    deleteTransactionConfirm: 'このトランザクションを削除しますか?残高は自動的に再計算されます。',
+    deposit: '預金',
+    withdraw: '引き出し',
+    printCharge: 'プリント料金',
+    deleteTransaction: 'トランザクションを削除',
   },
   // Printers page
   printers: {
@@ -355,6 +448,8 @@ export default {
     toast: {
       printerDeleted: 'プリンターを削除しました',
       missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
+      killSwitchTriggered: '課金キルスイッチが{{printer}}で未承認の印刷を停止しました:{{filename}}',
+      billingChargeFailed: '{{printer}} の {{filename}} を課金できませんでした。予算予約は保持されています。サーバーログを確認してください。',
       assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
       assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
@@ -2311,6 +2406,21 @@ export default {
     useTls: 'TLSを使用',
     enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
     availableMetrics: '利用可能なメトリクス',
+    billingEnabled: '課金を有効にする',
+    billingEnabledDescription: 'ユーザーにプリント料金を請求し、財務機能を有効にします',
+    printerKillSwitch: '印刷の自動停止',
+    printerKillSwitchDescription: '承認されていない印刷を即座に停止する',
+    financeBudgetReset: '月次財務予算のリセット',
+    financeBudgetResetDay: 'リセット日',
+    financeBudgetResetDayHelp: "短い月の場合、リセットは月の最終日を使用します。",
+    financeBudgetResetTimezone: 'リセットタイムゾーン',
+    financeBudgetResetTimezoneHelp: "L'inizio della finestra di bilancio viene calcolato in questo fuso orario.",
+    rebuildLedger: 'ウォレットレジャーを再構築',
+    rebuildLedgerStarted: 'レジャー再構築が開始されました',
+    rebuildLedgerConfirmTitle: 'ウォレットレジャーを再構築しますか?',
+    rebuildLedgerConfirmMessage:
+      'これにより、ウォレットレジャーが再構築され、履歴残高値が修復されます。何をしているかを確認した時にのみこれを実行してください。',
+    rebuildLedgerInProgress: '再構築を開始中...',
     editUser: 'ユーザーを編集',
     deleteUserTitle: 'ユーザーを削除',
     groupName: 'グループ名',
@@ -4799,6 +4909,9 @@ export default {
     staggerTotal: '合計: {{minutes}}分',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     gcodeInjection: '自動印刷G-codeを挿入',
+    insufficientBudget: '予算が不足しています',
+    unlimitedNoBudget: '無制限 – 予算上限は設定されていません。',
+    noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。',
   },
 
   // Backup
@@ -5792,6 +5905,8 @@ export default {
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     missingSpoolAssignmentLabel: 'スプール割り当て不足',
+    billingChargeFailedLabel: '請求処理エラー',
+    billingChargeFailedDescription: '印刷コストを記録できなかった場合に通知します',
     missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     printFailed: '印刷失敗',
     printStopped: '印刷停止',

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

@@ -8,6 +8,7 @@ export default {
     maintenance: '유지보수',
     projects: '프로젝트',
     inventory: '필라멘트',
+    finance: '재무',
     files: '파일 관리자',
     makerworld: 'MakerWorld',
     notifications: '알림',
@@ -120,7 +121,98 @@ export default {
     create: '만들기',
     duplicate: '복제',
     left: '왼쪽',
-    right: '오른쪽'
+    right: '오른쪽',
+    user: '사용자',
+    run: '실행',
+    running: '실행 중...'
+  },
+  finance: {
+    allTypes: '모든 유형',
+    allCostCenters: '모든 비용 센터',
+    title: '재무',
+    subtitle: '지갑, 개인 거래 및 비용 센터',
+    noAccess: '재무 데이터를 볼 권한이 없습니다.',
+    personalView: '개인 보기',
+    adminView: '관리자 보기',
+    createCostCenter: '비용 센터 만들기',
+    adjustWallet: '지갑 조정',
+    addManualPrint: '수동 인쇄 추가',
+    manageMembers: '비용 센터 구성원 관리',
+    currentBalance: '개인 잔액',
+    transactions: '거래',
+    personalTransactions: '개인 거래',
+    costCenters: '비용 센터',
+    availableForPrinting: '인쇄 할당 가능',
+    costCenterName: '이름',
+    budgetType: '예산 유형',
+    monthlyBudget: '월 예산',
+    totalBudget: '총예산',
+    noBudget: '예산 없음',
+    create: '만들기',
+    selectUser: '사용자 선택',
+    transactionType: '유형',
+    amount: '금액',
+    noCostCenter: '비용 센터 없음',
+    descriptionOptional: '설명(선택 사항)',
+    applyAdjustment: '조정 적용',
+    memberCanPrint: '구성원이 인쇄할 수 있음',
+    addMember: '구성원 추가',
+    canPrint: '인쇄 가능',
+    noMembers: '할당된 구성원이 없습니다.',
+    editCostCenter: '비용 센터 편집',
+    myCostCenters: '내 비용 센터',
+    costCentersHint: '예산 한도를 검토하고 비용을 관리하세요',
+    noCostCenters: '비용 센터를 찾을 수 없습니다.',
+    owner: '소유자',
+    balance: '계정 잔액',
+    unlimited: '무제한',
+    budgetPolicyHint: '계정 잔액은 비용을 기록합니다. 인쇄는 선택한 비용 센터의 예산으로만 제한되며, 예산이 없으면 무제한입니다.',
+    budget: '예산',
+    shared: '공유',
+    cannotEditPrivateCostCenter: '비공개 비용 센터는 여기에서 편집할 수 없습니다',
+    recentTransactions: '최근 거래',
+    transactionsHint: '유형 및 비용 센터별 필터링',
+    first: '처음',
+    prev: '이전',
+    next: '다음',
+    last: '마지막',
+    pageNumberOf: '{{total}}페이지 중 {{page}}페이지',
+    noTransactions: '사용 가능한 거래가 없습니다.',
+    noTransactionsForFilter: '선택한 필터와 일치하는 거래가 없습니다.',
+    costCenter: '비용 센터',
+    balanceAfter: '거래 후 잔액',
+    userWithId: '사용자 #{{id}}',
+    partial: '일부',
+    editTransaction: '거래 편집',
+    selectCostCenter: '비용 센터 선택...',
+    costCenterRequired: '비용 센터를 선택하세요',
+    amountExample: '예: 4.00',
+    manualAdjustmentExample: '예: 수동 조정',
+    userRequired: '사용자를 선택하세요',
+    amountInvalid: '금액은 유효한 숫자여야 합니다',
+    createdCostCenter: '비용 센터를 만들었습니다',
+    createCostCenterFailed: '비용 센터를 만들지 못했습니다',
+    transactionDeleted: '거래를 삭제했습니다',
+    deleteTransactionFailed: '거래를 삭제하지 못했습니다',
+    transactionEdited: '거래를 업데이트하고 원장을 다시 계산했습니다',
+    editTransactionFailed: '거래를 편집하지 못했습니다',
+    manualPrintCreated: '수동 인쇄 비용을 추가하고 원장을 다시 계산했습니다',
+    manualPrintFailed: '수동 인쇄를 만들지 못했습니다',
+    memberSaved: '구성원을 저장했습니다',
+    memberSaveFailed: '구성원을 저장하지 못했습니다',
+    memberRemoved: '구성원을 제거했습니다',
+    memberRemoveFailed: '구성원을 제거하지 못했습니다',
+    costCenterNameRequired: '비용 센터 이름은 필수입니다',
+    costCenterUpdated: '비용 센터를 업데이트했습니다',
+    costCenterUpdateFailed: '비용 센터를 업데이트하지 못했습니다',
+    confirmDeleteCostCenter: '비용 센터 "{{name}}"을(를) 삭제하시겠습니까?',
+    costCenterDeleted: '비용 센터를 삭제했습니다',
+    costCenterDeleteFailed: '비용 센터를 삭제하지 못했습니다',
+    deleteTransactionConfirm: '이 거래를 삭제하시겠습니까? 잔액은 자동으로 다시 계산됩니다.',
+    deposit: '입금',
+    withdraw: '출금',
+    printCharge: '인쇄 비용',
+    deleteTransaction: '거래 삭제',
   },
   printers: {
     title: '프린터',
@@ -331,6 +423,8 @@ export default {
     toast: {
       printerDeleted: '프린터가 삭제되었습니다',
       missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
+      killSwitchTriggered: '결제 킬 스위치가 {{printer}}에서 승인되지 않은 인쇄를 중지했습니다: {{filename}}',
+      billingChargeFailed: '{{printer}}의 {{filename}} 결제에 실패했습니다. 예산 예약은 유지되었습니다. 서버 로그를 확인하세요.',
       assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
       assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
@@ -2167,6 +2261,20 @@ export default {
     },
     externalCameras: '외부 카메라',
     costTracking: '비용 추적',
+    billingEnabled: '결제 기능 사용',
+    billingEnabledDescription: '사용자에게 인쇄 비용을 청구하고 재무 기능을 사용합니다',
+    printerKillSwitch: '무단 인쇄 자동 중지',
+    printerKillSwitchDescription: '승인 없이 시작된 인쇄를 즉시 중지합니다.',
+    financeBudgetReset: '월별 재무 예산 초기화',
+    financeBudgetResetDay: '초기화 날짜',
+    financeBudgetResetDayHelp: '해당 날짜가 없는 짧은 달에는 그 달의 마지막 날에 초기화합니다.',
+    financeBudgetResetTimezone: '초기화 시간대',
+    financeBudgetResetTimezoneHelp: '예산 기간의 시작은 이 시간대를 기준으로 계산됩니다.',
+    rebuildLedger: '지갑 원장 재구성',
+    rebuildLedgerStarted: '원장 재구성을 시작했습니다',
+    rebuildLedgerConfirmTitle: '지갑 원장을 재구성하시겠습니까?',
+    rebuildLedgerConfirmMessage: '과거 잔액 값을 복구하기 위해 지갑 원장을 재구성합니다. 수행 내용을 정확히 아는 경우에만 실행하세요.',
+    rebuildLedgerInProgress: '재구성 시작 중...',
     printsOnly: '인쇄만',
     totalConsumption: '총 소비',
     dataManagement: '데이터 관리',
@@ -4570,7 +4678,10 @@ export default {
     staggerLastGroup: '마지막 그룹: {{count}}',
     staggerTotal: '합계: {{minutes}}분',
     staggerToPrinters: '{{count}}대 프린터에 분산',
-    gcodeInjection: '자동 인쇄 G-code 삽입'
+    gcodeInjection: '자동 인쇄 G-code 삽입',
+    insufficientBudget: '예산 부족',
+    unlimitedNoBudget: '무제한 – 예산 한도가 설정되지 않았습니다.',
+    noPrintableCostCenters: '인쇄에 사용할 수 있는 활성 비용 센터가 없습니다. 관리자에게 인쇄 권한을 요청하세요.',
   },
   backup: {
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
@@ -5512,6 +5623,8 @@ export default {
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
     missingSpoolAssignmentLabel: '스풀 할당 누락',
+    billingChargeFailedLabel: '결제 처리 실패',
+    billingChargeFailedDescription: '인쇄 비용을 기록하지 못한 경우 알림',
     missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림',
     printFailed: '인쇄 실패',
     printStopped: '인쇄 중지됨',

+ 115 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Manutenção',
     projects: 'Projetos',
     inventory: 'Inventário',
+    finance: 'Finanças',
     files: 'Gerenciador de Arquivos',
     makerworld: 'MakerWorld',
     notifications: 'Notificações',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Duplicar',
     left: 'Esquerda',
     right: 'Direita',
+    user: 'Usuário',
+    run: 'Executar',
+    running: 'Executando...',
+  },
+
+  finance: {
+    allTypes: 'Todos os tipos',
+    allCostCenters: 'Todos os centros de custo',
+    title: 'Finanças',
+    subtitle: 'Carteira, transações pessoais e centros de custo',
+    noAccess: 'Você não tem permissão para visualizar dados financeiros.',
+    personalView: 'Visualização pessoal',
+    adminView: 'Visualização do administrador',
+    createCostCenter: 'Criar centro de custo',
+    adjustWallet: 'Ajustar carteira',
+    addManualPrint: 'Adicionar impressão manual',
+    manageMembers: 'Gerenciar membros do centro de custo',
+    currentBalance: 'Saldo pessoal',
+    transactions: 'Transações',
+    personalTransactions: 'Transações pessoais',
+    costCenters: 'Centros de custo',
+    availableForPrinting: 'Disponível para atribuição de impressão',
+    costCenterName: 'Nome',
+    budgetType: 'Tipo de orçamento',
+    monthlyBudget: 'Orçamento mensal',
+    totalBudget: 'Orçamento total',
+    noBudget: 'Sem orçamento',
+    create: 'Criar',
+    selectUser: 'Selecionar usuário',
+    transactionType: 'Tipo',
+    amount: 'Valor',
+    noCostCenter: 'Sem centro de custo',
+    descriptionOptional: 'Descrição (opcional)',
+    applyAdjustment: 'Aplicar ajuste',
+    memberCanPrint: 'Membro pode imprimir',
+    addMember: 'Adicionar membro',
+    canPrint: 'Pode imprimir',
+    noMembers: 'Nenhum membro atribuído.',
+    editCostCenter: 'Editar centro de custo',
+    myCostCenters: 'Meus centros de custo',
+    costCentersHint: 'Revise os limites de orçamento e mantenha os custos sob controle',
+    noCostCenters: 'Nenhum centro de custo encontrado.',
+    owner: 'Proprietário',
+    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',
+    recentTransactions: 'Transações recentes',
+    transactionsHint: 'Filtre por tipo e centro de custo',
+    first: 'Primeira',
+    prev: 'Anterior',
+    next: 'Próxima',
+    last: 'Última',
+    pageNumberOf: 'Página {{page}} de {{total}}',
+    noTransactions: 'Nenhuma transação disponível.',
+    noTransactionsForFilter: 'Nenhuma transação corresponde aos filtros selecionados.',
+    costCenter: 'Centro de custo',
+    balanceAfter: 'Saldo após',
+    userWithId: 'Usuário #{{id}}',
+    partial: 'Parcial',
+    editTransaction: 'Editar transação',
+    selectCostCenter: 'Selecionar centro de custo...',
+    costCenterRequired: 'Por favor, selecione um centro de custo',
+    amountExample: 'ex.: 4,00',
+    manualAdjustmentExample: 'ex.: Ajuste manual',
+    userRequired: 'Por favor, selecione um usuário',
+    amountInvalid: 'O valor deve ser um número válido',
+    createdCostCenter: 'Centro de custo criado',
+    createCostCenterFailed: 'Falha ao criar o centro de custo',
+    transactionDeleted: 'Transação excluída',
+    deleteTransactionFailed: 'Falha ao excluir a transação',
+    transactionEdited: 'Transação atualizada e razão recalculada',
+    editTransactionFailed: 'Falha ao editar a transação',
+    manualPrintCreated: 'Cobrança de impressão manual adicionada e razão recalculada',
+    manualPrintFailed: 'Falha ao criar a impressão manual',
+    memberSaved: 'Membro salvo',
+    memberSaveFailed: 'Falha ao salvar o membro',
+    memberRemoved: 'Membro removido',
+    memberRemoveFailed: 'Falha ao remover o membro',
+    costCenterNameRequired: 'O nome do centro de custo é obrigatório',
+    costCenterUpdated: 'Centro de custo atualizado',
+    costCenterUpdateFailed: 'Falha ao atualizar o centro de custo',
+    confirmDeleteCostCenter: 'Excluir o centro de custo "{{name}}"?',
+    costCenterDeleted: 'Centro de custo excluído',
+    costCenterDeleteFailed: 'Falha ao excluir o centro de custo',
+    deleteTransactionConfirm: 'Excluir essa transação? Os saldos serão recalculados automaticamente.',
+    deposit: 'Depósito',
+    withdraw: 'Saque',
+    printCharge: 'Cobrança de impressão',
+    deleteTransaction: 'Excluir transação',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       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',
@@ -2268,6 +2363,21 @@ export default {
     useTls: 'Usar TLS',
     enableMetricsEndpoint: 'Ativar Endpoint de Métricas',
     availableMetrics: 'Métricas Disponíveis',
+    billingEnabled: 'Habilitar cobrança',
+    billingEnabledDescription: 'Cobrar dos usuários por impressões e habilitar recursos de financeiro',
+    printerKillSwitch: 'Parada Automática de Impressão',
+    printerKillSwitchDescription: 'Interromper imediatamente impressões não autorizadas',
+    financeBudgetReset: 'Redefinir Orçamento Financeiro Mensal',
+    financeBudgetResetDay: 'Dia de Redefinição',
+    financeBudgetResetDayHelp: 'Para meses curtos, a redefinição utiliza o último dia do mês.',
+    financeBudgetResetTimezone: 'Fuso Horário de Redefinição',
+    financeBudgetResetTimezoneHelp: 'Der Budget-Fenster-Start wird in dieser Zeitzone berechnet.',
+    rebuildLedger: 'Reconstruir razão da carteira',
+    rebuildLedgerStarted: 'Reconstrução da razão iniciada',
+    rebuildLedgerConfirmTitle: 'Reconstruir razão da carteira?',
+    rebuildLedgerConfirmMessage:
+      'Isto reconstruirá a razão da carteira para reparar valores de saldo histórico. Execute isso apenas se você souber o que está fazendo.',
+    rebuildLedgerInProgress: 'Iniciando reconstrução...',
     editUser: 'Editar Usuário',
     deleteUserTitle: 'Excluir Usuário',
     groupName: 'Nome do Grupo',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     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
@@ -5780,6 +5893,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',

+ 113 - 0
frontend/src/i18n/locales/ru.ts

@@ -8,6 +8,7 @@ export default {
     maintenance: "Обслуживание",
     projects: "Проекты",
     inventory: "Филамент",
+    finance: "Финансы",
     files: "Файловый менеджер",
     makerworld: "MakerWorld",
     notifications: "Уведомления",
@@ -121,6 +122,97 @@ export default {
     duplicate: "Дублировать",
     left: "Левый",
     right: "Правый",
+    user: "Пользователь",
+    run: "Запустить",
+    running: "Выполняется...",
+  },
+  finance: {
+    allTypes: "Все типы",
+    allCostCenters: "Все центры затрат",
+    title: "Финансы",
+    subtitle: "Кошелёк, личные операции и центры затрат",
+    noAccess: "У вас нет разрешения на просмотр финансовых данных.",
+    personalView: "Личный режим",
+    adminView: "Режим администратора",
+    createCostCenter: "Создать центр затрат",
+    adjustWallet: "Изменить кошелёк",
+    addManualPrint: "Добавить печать вручную",
+    manageMembers: "Управление участниками центра затрат",
+    currentBalance: "Личный баланс",
+    transactions: "Операции",
+    personalTransactions: "Личные операции",
+    costCenters: "Центры затрат",
+    availableForPrinting: "Доступен для назначения печати",
+    costCenterName: "Название",
+    budgetType: "Тип бюджета",
+    monthlyBudget: "Месячный бюджет",
+    totalBudget: "Общий бюджет",
+    noBudget: "Без бюджета",
+    create: "Создать",
+    selectUser: "Выберите пользователя",
+    transactionType: "Тип",
+    amount: "Сумма",
+    noCostCenter: "Без центра затрат",
+    descriptionOptional: "Описание (необязательно)",
+    applyAdjustment: "Применить изменение",
+    memberCanPrint: "Участник может печатать",
+    addMember: "Добавить участника",
+    canPrint: "Может печатать",
+    noMembers: "Участники не назначены.",
+    editCostCenter: "Изменить центр затрат",
+    myCostCenters: "Мои центры затрат",
+    costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
+    noCostCenters: "Центры затрат не найдены.",
+    owner: "Владелец",
+    balance: "Баланс счёта",
+    unlimited: "Без ограничений",
+    budgetPolicyHint: "Баланс счёта используется для учёта затрат. Печать ограничивается только бюджетом выбранного центра затрат; без бюджета ограничений нет.",
+    budget: "Бюджет",
+    shared: "Общий",
+    cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
+    recentTransactions: "Недавние операции",
+    transactionsHint: "Фильтр по типу и центру затрат",
+    first: "Первая",
+    prev: "Предыдущая",
+    next: "Следующая",
+    last: "Последняя",
+    pageNumberOf: "Страница {{page}} из {{total}}",
+    noTransactions: "Нет доступных операций.",
+    noTransactionsForFilter: "Нет операций, соответствующих выбранным фильтрам.",
+    costCenter: "Центр затрат",
+    balanceAfter: "Баланс после операции",
+    userWithId: "Пользователь №{{id}}",
+    partial: "Частично",
+    editTransaction: "Изменить операцию",
+    selectCostCenter: "Выберите центр затрат...",
+    costCenterRequired: "Выберите центр затрат",
+    amountExample: "например, 4,00",
+    manualAdjustmentExample: "например, ручная корректировка",
+    userRequired: "Выберите пользователя",
+    amountInvalid: "Сумма должна быть допустимым числом",
+    createdCostCenter: "Центр затрат создан",
+    createCostCenterFailed: "Не удалось создать центр затрат",
+    transactionDeleted: "Операция удалена",
+    deleteTransactionFailed: "Не удалось удалить операцию",
+    transactionEdited: "Операция обновлена, бухгалтерская книга пересчитана",
+    editTransactionFailed: "Не удалось изменить операцию",
+    manualPrintCreated: "Расход на печать добавлен, бухгалтерская книга пересчитана",
+    manualPrintFailed: "Не удалось добавить печать вручную",
+    memberSaved: "Участник сохранён",
+    memberSaveFailed: "Не удалось сохранить участника",
+    memberRemoved: "Участник удалён",
+    memberRemoveFailed: "Не удалось удалить участника",
+    costCenterNameRequired: "Необходимо указать название центра затрат",
+    costCenterUpdated: "Центр затрат обновлён",
+    costCenterUpdateFailed: "Не удалось обновить центр затрат",
+    confirmDeleteCostCenter: "Удалить центр затрат «{{name}}»?",
+    costCenterDeleted: "Центр затрат удалён",
+    costCenterDeleteFailed: "Не удалось удалить центр затрат",
+    deleteTransactionConfirm: "Удалить эту операцию? Балансы будут пересчитаны автоматически.",
+    deposit: "Пополнение",
+    withdraw: "Списание",
+    printCharge: "Расход на печать",
+    deleteTransaction: "Удалить операцию",
   },
   printers: {
     title: "Принтеры",
@@ -336,6 +428,8 @@ export default {
     toast: {
       printerDeleted: "Принтер удалён",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
+      killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не удалось начислить стоимость {{filename}} на {{printer}}. Резерв бюджета сохранён; проверьте журналы сервера.',
       assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
       assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
       assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
@@ -2168,6 +2262,20 @@ export default {
     },
     externalCameras: "Внешние камеры",
     costTracking: "Учёт затрат",
+    billingEnabled: "Включить расчёты",
+    billingEnabledDescription: "Списывать с пользователей стоимость печати и включить финансовые функции",
+    printerKillSwitch: "Автоостановка несанкционированной печати",
+    printerKillSwitchDescription: "Немедленно останавливает печать, начатую без разрешения.",
+    financeBudgetReset: "Ежемесячный сброс финансового бюджета",
+    financeBudgetResetDay: "День сброса",
+    financeBudgetResetDayHelp: "В коротких месяцах сброс выполняется в последний день месяца.",
+    financeBudgetResetTimezone: "Часовой пояс сброса",
+    financeBudgetResetTimezoneHelp: "Начало бюджетного периода рассчитывается в этом часовом поясе.",
+    rebuildLedger: "Пересчитать книгу кошелька",
+    rebuildLedgerStarted: "Пересчёт книги запущен",
+    rebuildLedgerConfirmTitle: "Пересчитать книгу кошелька?",
+    rebuildLedgerConfirmMessage: "Книга кошелька будет пересчитана для исправления исторических значений баланса. Запускайте это действие только если понимаете его последствия.",
+    rebuildLedgerInProgress: "Запуск пересчёта...",
     printsOnly: "Только печать",
     totalConsumption: "Общее потребление",
     dataManagement: "Управление данными",
@@ -4560,6 +4668,9 @@ export default {
     staggerTotal: "всего: {{minutes}} мин",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     gcodeInjection: "Добавить G-code автозапуска",
+    insufficientBudget: "Недостаточно бюджета",
+    unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
+    noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
   },
   backup: {
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5499,6 +5610,8 @@ export default {
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
     missingSpoolAssignmentLabel: "Катушка не назначена",
+    billingChargeFailedLabel: 'Ошибка списания',
+    billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати',
     missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
     printFailed: "Ошибка печати",
     printStopped: "Печать остановлена",

+ 114 - 0
frontend/src/i18n/locales/tr.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Bakım',
     projects: 'Projeler',
     inventory: 'Filament',
+    finance: 'Finans',
     files: 'Dosya Yöneticisi',
     makerworld: 'MakerWorld',
     notifications: 'Bildirimler',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Çoğalt',
     left: 'Sol',
     right: 'Sağ',
+    user: 'Kullanıcı',
+    run: 'Çalıştır',
+    running: 'Çalışıyor...',
+  },
+
+  finance: {
+    allTypes: 'Tüm türler',
+    allCostCenters: 'Tüm masraf merkezleri',
+    title: 'Finans',
+    subtitle: 'Cüzdan, kişisel işlemler ve masraf merkezleri',
+    noAccess: 'Finans verilerini görüntüleme izniniz yok.',
+    personalView: 'Kişisel görünüm',
+    adminView: 'Yönetici görünümü',
+    createCostCenter: 'Masraf merkezi oluştur',
+    adjustWallet: 'Cüzdanı ayarla',
+    addManualPrint: 'Manuel baskı ekle',
+    manageMembers: 'Masraf merkezi üyelerini yönet',
+    currentBalance: 'Kişisel bakiye',
+    transactions: 'İşlemler',
+    personalTransactions: 'Kişisel işlemler',
+    costCenters: 'Masraf merkezleri',
+    availableForPrinting: 'Baskı ataması için kullanılabilir',
+    costCenterName: 'Ad',
+    budgetType: 'Bütçe türü',
+    monthlyBudget: 'Aylık bütçe',
+    totalBudget: 'Toplam bütçe',
+    noBudget: 'Bütçe yok',
+    create: 'Oluştur',
+    selectUser: 'Kullanıcı seç',
+    transactionType: 'Tür',
+    amount: 'Tutar',
+    noCostCenter: 'Masraf merkezi yok',
+    descriptionOptional: 'Açıklama (isteğe bağlı)',
+    applyAdjustment: 'Ayarlamayı uygula',
+    memberCanPrint: 'Üye baskı yapabilir',
+    addMember: 'Üye ekle',
+    canPrint: 'Baskı yapabilir',
+    noMembers: 'Atanmış üye yok.',
+    editCostCenter: 'Masraf merkezini düzenle',
+    myCostCenters: 'Masraf merkezlerim',
+    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: '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',
+    recentTransactions: 'Son işlemler',
+    transactionsHint: 'Türe ve masraf merkezine göre filtrele',
+    first: 'İlk',
+    prev: 'Önceki',
+    next: 'Sonraki',
+    last: 'Son',
+    pageNumberOf: '{{total}} sayfanın {{page}}. sayfası',
+    noTransactions: 'Kullanılabilir işlem yok.',
+    noTransactionsForFilter: 'Seçilen filtrelerle eşleşen işlem yok.',
+    costCenter: 'Masraf merkezi',
+    balanceAfter: 'İşlem sonrası bakiye',
+    userWithId: 'Kullanıcı #{{id}}',
+    partial: 'Kısmi',
+    editTransaction: 'İşlemi düzenle',
+    selectCostCenter: 'Masraf merkezi seç...',
+    costCenterRequired: 'Lütfen bir masraf merkezi seçin',
+    amountExample: 'örn. 4,00',
+    manualAdjustmentExample: 'örn. Manuel ayarlama',
+    userRequired: 'Lütfen bir kullanıcı seçin',
+    amountInvalid: 'Tutar geçerli bir sayı olmalıdır',
+    createdCostCenter: 'Masraf merkezi oluşturuldu',
+    createCostCenterFailed: 'Masraf merkezi oluşturulamadı',
+    transactionDeleted: 'İşlem silindi',
+    deleteTransactionFailed: 'İşlem silinemedi',
+    transactionEdited: 'İşlem güncellendi ve hesap defteri yeniden hesaplandı',
+    editTransactionFailed: 'İşlem düzenlenemedi',
+    manualPrintCreated: 'Manuel baskı ücreti eklendi ve hesap defteri yeniden hesaplandı',
+    manualPrintFailed: 'Manuel baskı oluşturulamadı',
+    memberSaved: 'Üye kaydedildi',
+    memberSaveFailed: 'Üye kaydedilemedi',
+    memberRemoved: 'Üye kaldırıldı',
+    memberRemoveFailed: 'Üye kaldırılamadı',
+    costCenterNameRequired: 'Masraf merkezi adı gereklidir',
+    costCenterUpdated: 'Masraf merkezi güncellendi',
+    costCenterUpdateFailed: 'Masraf merkezi güncellenemedi',
+    confirmDeleteCostCenter: '"{{name}}" masraf merkezi silinsin mi?',
+    costCenterDeleted: 'Masraf merkezi silindi',
+    costCenterDeleteFailed: 'Masraf merkezi silinemedi',
+    deleteTransactionConfirm: 'Bu işlem silinsin mi? Bakiyeler otomatik olarak yeniden hesaplanacaktır.',
+    deposit: 'Para yatırma',
+    withdraw: 'Para çekme',
+    printCharge: 'Baskı ücreti',
+    deleteTransaction: 'İşlemi sil',
   },
 
   // Yazıcılar sayfası
@@ -356,6 +449,8 @@ export default {
     toast: {
       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',
@@ -2298,6 +2393,20 @@ export default {
     },
     externalCameras: 'Harici Kameralar',
     costTracking: 'Maliyet Takibi',
+    billingEnabled: 'Faturalandırmayı etkinleştir',
+    billingEnabledDescription: 'Kullanıcılardan baskı ücreti alın ve finans özelliklerini etkinleştirin',
+    printerKillSwitch: 'Yetkisiz baskıyı otomatik durdurma',
+    printerKillSwitchDescription: 'Yetkilendirme olmadan başlayan baskıları hemen durdurur.',
+    financeBudgetReset: 'Aylık finans bütçesi sıfırlama',
+    financeBudgetResetDay: 'Sıfırlama günü',
+    financeBudgetResetDayHelp: 'Kısa aylarda sıfırlama ayın son gününde yapılır.',
+    financeBudgetResetTimezone: 'Sıfırlama saat dilimi',
+    financeBudgetResetTimezoneHelp: 'Bütçe döneminin başlangıcı bu saat dilimine göre hesaplanır.',
+    rebuildLedger: 'Cüzdan hesap defterini yeniden oluştur',
+    rebuildLedgerStarted: 'Hesap defteri yeniden oluşturulmaya başlandı',
+    rebuildLedgerConfirmTitle: 'Cüzdan hesap defteri yeniden oluşturulsun mu?',
+    rebuildLedgerConfirmMessage: 'Geçmiş bakiye değerlerini onarmak için cüzdan hesap defteri yeniden oluşturulacaktır. Bunu yalnızca ne yaptığınızı biliyorsanız çalıştırın.',
+    rebuildLedgerInProgress: 'Yeniden oluşturma başlatılıyor...',
     printsOnly: 'Yalnızca Baskılar',
     totalConsumption: 'Toplam Tüketim',
     dataManagement: 'Veri Yönetimi',
@@ -4777,6 +4886,9 @@ export default {
     staggerTotal: 'toplam: {{minutes}} dk',
     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
@@ -5748,6 +5860,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',

+ 114 - 0
frontend/src/i18n/locales/uk.ts

@@ -9,6 +9,7 @@ export default {
     maintenance: "Технічне обслуговування",
     projects: "Проєкти",
     inventory: "Філамент",
+    finance: "Фінанси",
     files: "Менеджер файлів",
     makerworld: "MakerWorld",
     notifications: "Сповіщення",
@@ -124,6 +125,98 @@ export default {
     duplicate: "Дублювати",
     left: "Ліворуч",
     right: "Праворуч",
+    user: "Користувач",
+    run: "Запустити",
+    running: "Виконується...",
+  },
+
+  finance: {
+    allTypes: "Усі типи",
+    allCostCenters: "Усі центри витрат",
+    title: "Фінанси",
+    subtitle: "Гаманець, особисті операції та центри витрат",
+    noAccess: "У вас немає дозволу на перегляд фінансових даних.",
+    personalView: "Особистий режим",
+    adminView: "Режим адміністратора",
+    createCostCenter: "Створити центр витрат",
+    adjustWallet: "Скоригувати гаманець",
+    addManualPrint: "Додати ручний друк",
+    manageMembers: "Керувати учасниками центру витрат",
+    currentBalance: "Особистий баланс",
+    transactions: "Операції",
+    personalTransactions: "Особисті операції",
+    costCenters: "Центри витрат",
+    availableForPrinting: "Доступний для призначення друку",
+    costCenterName: "Назва",
+    budgetType: "Тип бюджету",
+    monthlyBudget: "Місячний бюджет",
+    totalBudget: "Загальний бюджет",
+    noBudget: "Без бюджету",
+    create: "Створити",
+    selectUser: "Виберіть користувача",
+    transactionType: "Тип",
+    amount: "Сума",
+    noCostCenter: "Без центру витрат",
+    descriptionOptional: "Опис (необов’язково)",
+    applyAdjustment: "Застосувати коригування",
+    memberCanPrint: "Учасник може друкувати",
+    addMember: "Додати учасника",
+    canPrint: "Може друкувати",
+    noMembers: "Учасників не призначено.",
+    editCostCenter: "Редагувати центр витрат",
+    myCostCenters: "Мої центри витрат",
+    costCentersHint: "Переглядайте бюджетні ліміти та контролюйте витрати",
+    noCostCenters: "Центрів витрат не знайдено.",
+    owner: "Власник",
+    balance: "Баланс рахунку",
+    unlimited: "Без обмежень",
+    budgetPolicyHint: "Баланс рахунку використовується для обліку витрат. Друк обмежується лише бюджетом вибраного центру витрат; без бюджету обмежень немає.",
+    budget: "Бюджет",
+    shared: "Спільний",
+    cannotEditPrivateCostCenter: "Особисті центри витрат не можна редагувати тут",
+    recentTransactions: "Останні операції",
+    transactionsHint: "Фільтрувати за типом і центром витрат",
+    first: "Перша",
+    prev: "Попередня",
+    next: "Наступна",
+    last: "Остання",
+    pageNumberOf: "Сторінка {{page}} з {{total}}",
+    noTransactions: "Немає доступних операцій.",
+    noTransactionsForFilter: "Немає операцій, що відповідають вибраним фільтрам.",
+    costCenter: "Центр витрат",
+    balanceAfter: "Баланс після операції",
+    userWithId: "Користувач №{{id}}",
+    partial: "Частково",
+    editTransaction: "Редагувати операцію",
+    selectCostCenter: "Виберіть центр витрат...",
+    costCenterRequired: "Виберіть центр витрат",
+    amountExample: "наприклад, 4,00",
+    manualAdjustmentExample: "наприклад, ручне коригування",
+    userRequired: "Виберіть користувача",
+    amountInvalid: "Сума має бути дійсним числом",
+    createdCostCenter: "Центр витрат створено",
+    createCostCenterFailed: "Не вдалося створити центр витрат",
+    transactionDeleted: "Операцію видалено",
+    deleteTransactionFailed: "Не вдалося видалити операцію",
+    transactionEdited: "Операцію оновлено, бухгалтерську книгу перераховано",
+    editTransactionFailed: "Не вдалося редагувати операцію",
+    manualPrintCreated: "Витрати на ручний друк додано, бухгалтерську книгу перераховано",
+    manualPrintFailed: "Не вдалося додати ручний друк",
+    memberSaved: "Учасника збережено",
+    memberSaveFailed: "Не вдалося зберегти учасника",
+    memberRemoved: "Учасника видалено",
+    memberRemoveFailed: "Не вдалося видалити учасника",
+    costCenterNameRequired: "Назва центру витрат є обов’язковою",
+    costCenterUpdated: "Центр витрат оновлено",
+    costCenterUpdateFailed: "Не вдалося оновити центр витрат",
+    confirmDeleteCostCenter: "Видалити центр витрат «{{name}}»?",
+    costCenterDeleted: "Центр витрат видалено",
+    costCenterDeleteFailed: "Не вдалося видалити центр витрат",
+    deleteTransactionConfirm: "Видалити цю операцію? Баланси буде перераховано автоматично.",
+    deposit: "Поповнення",
+    withdraw: "Списання",
+    printCharge: "Витрати на друк",
+    deleteTransaction: "Видалити операцію",
   },
 
   // Printers page
@@ -359,6 +452,8 @@ export default {
     toast: {
       printerDeleted: "Принтер видалено",
       missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
+      killSwitchTriggered: 'Аварійний вимикач білінгу зупинив несанкціонований друк на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не вдалося нарахувати вартість {{filename}} на {{printer}}. Резерв бюджету збережено; перевірте журнали сервера.',
       assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
       assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
       assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
@@ -2313,6 +2408,20 @@ export default {
     },
     externalCameras: "Зовнішні камери",
     costTracking: "Відстеження витрат",
+    billingEnabled: "Увімкнути розрахунки",
+    billingEnabledDescription: "Стягувати з користувачів вартість друку та ввімкнути фінансові функції",
+    printerKillSwitch: "Автозупинка несанкціонованого друку",
+    printerKillSwitchDescription: "Негайно зупиняє друк, розпочатий без дозволу.",
+    financeBudgetReset: "Щомісячне скидання фінансового бюджету",
+    financeBudgetResetDay: "День скидання",
+    financeBudgetResetDayHelp: "У коротких місяцях скидання виконується в останній день місяця.",
+    financeBudgetResetTimezone: "Часовий пояс скидання",
+    financeBudgetResetTimezoneHelp: "Початок бюджетного періоду обчислюється в цьому часовому поясі.",
+    rebuildLedger: "Перебудувати книгу гаманця",
+    rebuildLedgerStarted: "Перебудову книги розпочато",
+    rebuildLedgerConfirmTitle: "Перебудувати книгу гаманця?",
+    rebuildLedgerConfirmMessage: "Книгу гаманця буде перебудовано для виправлення історичних значень балансу. Запускайте цю дію лише якщо розумієте її наслідки.",
+    rebuildLedgerInProgress: "Запуск перебудови...",
     printsOnly: "Лише друк",
     totalConsumption: "Загальне споживання",
     dataManagement: "Управління даними",
@@ -4842,6 +4951,9 @@ export default {
     staggerTotal: "всього: {{minutes}} хв",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     gcodeInjection: "Додати G-код автоматичного друку",
+    insufficientBudget: "Недостатньо бюджету",
+    unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.",
+    noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.",
   },
 
   // Backup
@@ -5835,6 +5947,8 @@ export default {
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
     missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
+    billingChargeFailedLabel: 'Помилка списання',
+    billingChargeFailedDescription: 'Сповіщати, якщо не вдалося облікувати вартість друку',
     missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
     printFailed: "Помилка друку",
     printStopped: "Друк зупинено",

+ 115 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -8,6 +8,7 @@ export default {
     profiles: '配置文件',
     maintenance: '维护',
     projects: '项目',
+    finance: '财务',
     inventory: '耗材',
     files: '文件管理器',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: '操作',
     status: '状态',
     name: '名称',
+    user: '用户',
     description: '描述',
     date: '日期',
     time: '时间',
@@ -124,6 +126,97 @@ export default {
     duplicate: '复制',
     left: '左',
     right: '右',
+    run: '运行',
+    running: '运行中...',
+  },
+
+  finance: {
+    allTypes: '所有类型',
+    allCostCenters: '所有成本中心',
+    title: '财务',
+    subtitle: '钱包、个人交易和成本中心',
+    noAccess: '您没有权限查看财务数据。',
+    personalView: '个人视图',
+    adminView: '管理员视图',
+    createCostCenter: '创建成本中心',
+    adjustWallet: '调整钱包',
+    addManualPrint: '添加手动打印',
+    manageMembers: '管理成本中心成员',
+    currentBalance: '个人余额',
+    transactions: '交易',
+    personalTransactions: '个人交易',
+    costCenters: '成本中心',
+    availableForPrinting: '可用于打印分配',
+    costCenterName: '名称',
+    budgetType: '预算类型',
+    monthlyBudget: '月度预算',
+    totalBudget: '总预算',
+    noBudget: '无预算',
+    create: '创建',
+    selectUser: '选择用户',
+    transactionType: '类型',
+    amount: '金额',
+    noCostCenter: '无成本中心',
+    descriptionOptional: '描述(可选)',
+    applyAdjustment: '应用调整',
+    memberCanPrint: '成员可以打印',
+    addMember: '添加成员',
+    canPrint: '可以打印',
+    noMembers: '未分配任何成员。',
+    editCostCenter: '编辑成本中心',
+    myCostCenters: '我的成本中心',
+    costCentersHint: '审查预算限制并将成本控制在可控范围内',
+    noCostCenters: '未找到成本中心。',
+    owner: '所有者',
+    balance: '账户余额',
+    unlimited: '无限制',
+    budgetPolicyHint: '账户余额用于记录成本。打印仅受所选成本中心预算限制;未设置预算时不受限制。',
+    budget: '预算',
+    shared: '共享',
+    cannotEditPrivateCostCenter: '私人成本中心无法在此处编辑',
+    recentTransactions: '最近的交易',
+    transactionsHint: '按类型和成本中心筛选,然后浏览页面',
+    first: '首页',
+    prev: '上一页',
+    next: '下一页',
+    last: '末页',
+    pageNumberOf: '第 {{page}} 页,共 {{total}} 页',
+    noTransactions: '没有可用的交易。',
+    noTransactionsForFilter: '没有交易与选定的筛选器相匹配。',
+    costCenter: '成本中心',
+    balanceAfter: '调整后余额',
+    userWithId: '用户 #{{id}}',
+    partial: '部分',
+    editTransaction: '编辑交易',
+    selectCostCenter: '选择成本中心...',
+    costCenterRequired: '请选择一个成本中心',
+    amountExample: '例如 4.00',
+    manualAdjustmentExample: '例如 手动调整',
+    userRequired: '请选择一个用户',
+    amountInvalid: '金额必须是有效的数字',
+    createdCostCenter: '成本中心已创建',
+    createCostCenterFailed: '创建成本中心失败',
+    transactionDeleted: '交易已删除',
+    deleteTransactionFailed: '删除交易失败',
+    transactionEdited: '交易已更新,账本已重新计算',
+    editTransactionFailed: '编辑交易失败',
+    manualPrintCreated: '手动打印费用已添加,账本已重新计算',
+    manualPrintFailed: '创建手动打印失败',
+    memberSaved: '成员已保存',
+    memberSaveFailed: '保存成员失败',
+    memberRemoved: '成员已移除',
+    memberRemoveFailed: '移除成员失败',
+    costCenterNameRequired: '成本中心名称为必填项',
+    costCenterUpdated: '成本中心已更新',
+    costCenterUpdateFailed: '更新成本中心失败',
+    confirmDeleteCostCenter: '删除成本中心 "{{name}}" ?',
+    costCenterDeleted: '成本中心已删除',
+    costCenterDeleteFailed: '删除成本中心失败',
+    deleteTransactionConfirm: '删除此交易?余额将自动重新计算。',
+    deposit: '存款',
+    withdraw: '取款',
+    printCharge: '打印费用',
+    deleteTransaction: '删除交易',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       printerDeleted: '打印机已删除',
       missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
+      killSwitchTriggered: '计费终止开关已停止 {{printer}} 上的未授权打印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 计费失败。预算预留已保留;请检查服务器日志。',
       assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
       assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
@@ -2313,6 +2408,21 @@ export default {
     useTls: '使用 TLS',
     enableMetricsEndpoint: '启用指标端点',
     availableMetrics: '可用指标',
+    billingEnabled: '启用账单',
+    billingEnabledDescription: '对用户的打印进行收费并启用财务功能',
+    printerKillSwitch: '打印机紧急停止开关',
+    printerKillSwitchDescription: '立即停止未经授权的打印',
+    financeBudgetReset: '财务预算月度重置',
+    financeBudgetResetDay: '重置日期',
+    financeBudgetResetDayHelp: '对于少数月份,重置使用该月的最后一天。',
+    financeBudgetResetTimezone: '重置时区',
+    financeBudgetResetTimezoneHelp: '预算窗口的开始时间在此时区计算。',
+    rebuildLedger: '重建钱包账本',
+    rebuildLedgerStarted: '钱包账本重建已启动',
+    rebuildLedgerConfirmTitle: '重建钱包账本?',
+    rebuildLedgerConfirmMessage:
+      '这将重建钱包账本以修复历史余额值。仅在您知道自己在做什么时才执行此操作。',
+    rebuildLedgerInProgress: '正在启动重建...',
     editUser: '编辑用户',
     deleteUserTitle: '删除用户',
     groupName: '组名称',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: '共 {{minutes}} 分钟',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     gcodeInjection: '注入自动打印G-code',
+    insufficientBudget: '预算不足',
+    unlimitedNoBudget: '无限制 – 未设置预算上限。',
+    noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。',
   },
 
   // Backup
@@ -5780,6 +5893,8 @@ export default {
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '计费失败',
+    billingChargeFailedDescription: '无法记录打印费用时通知',
     missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     printFailed: '打印失败',
     printStopped: '打印已停止',

+ 115 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -8,6 +8,7 @@ export default {
     profiles: '設定檔案',
     maintenance: '維護',
     projects: '專案',
+    finance: '財務',
     inventory: '耗材',
     files: '檔案管理器',
     makerworld: 'MakerWorld',
@@ -124,6 +125,98 @@ export default {
     duplicate: '複製',
     left: '左',
     right: '右',
+    user: '使用者',
+    run: '執行',
+    running: '執行中...',
+  },
+
+  finance: {
+    allTypes: '所有類型',
+    allCostCenters: '所有成本中心',
+    title: '財務',
+    subtitle: '錢包、個人交易和成本中心',
+    noAccess: '您沒有權限查看財務資料。',
+    personalView: '個人檢視',
+    adminView: '管理員檢視',
+    createCostCenter: '建立成本中心',
+    adjustWallet: '調整錢包',
+    addManualPrint: '新增手動列印',
+    manageMembers: '管理成本中心成員',
+    currentBalance: '個人餘額',
+    transactions: '交易',
+    personalTransactions: '個人交易',
+    costCenters: '成本中心',
+    availableForPrinting: '可用於列印分配',
+    costCenterName: '名稱',
+    budgetType: '預算類型',
+    monthlyBudget: '月度預算',
+    totalBudget: '總預算',
+    noBudget: '無預算',
+    create: '建立',
+    selectUser: '選擇使用者',
+    transactionType: '類型',
+    amount: '金額',
+    noCostCenter: '無成本中心',
+    descriptionOptional: '說明(可選)',
+    applyAdjustment: '套用調整',
+    memberCanPrint: '成員可以列印',
+    addMember: '新增成員',
+    canPrint: '可以列印',
+    noMembers: '未分配任何成員。',
+    editCostCenter: '編輯成本中心',
+    myCostCenters: '我的成本中心',
+    costCentersHint: '檢視預算限制並將成本保持在控制範圍內',
+    noCostCenters: '未找到成本中心。',
+    owner: '擁有者',
+    balance: '帳戶餘額',
+    unlimited: '無限制',
+    budgetPolicyHint: '帳戶餘額用於記錄成本。列印僅受所選成本中心預算限制;未設定預算時不受限制。',
+    budget: '預算',
+    shared: '共用',
+    cannotEditPrivateCostCenter: '無法在此處編輯私人成本中心',
+    recentTransactions: '最近的交易',
+    transactionsHint: '按類型和成本中心篩選,然後瀏覽頁面',
+    first: '首頁',
+    prev: '上一頁',
+    next: '下一頁',
+    last: '末頁',
+    pageNumberOf: '第 {{page}} 頁,共 {{total}} 頁',
+    noTransactions: '沒有可用的交易。',
+    noTransactionsForFilter: '沒有交易符合選定的篩選條件。',
+    costCenter: '成本中心',
+    balanceAfter: '調整後餘額',
+    userWithId: '使用者 #{{id}}',
+    partial: '部分',
+    editTransaction: '編輯交易',
+    selectCostCenter: '選擇成本中心...',
+    costCenterRequired: '請選擇一個成本中心',
+    amountExample: '例如 4.00',
+    manualAdjustmentExample: '例如 手動調整',
+    userRequired: '請選擇一個使用者',
+    amountInvalid: '金額必須是有效的數字',
+    createdCostCenter: '成本中心已建立',
+    createCostCenterFailed: '建立成本中心失敗',
+    transactionDeleted: '交易已刪除',
+    deleteTransactionFailed: '刪除交易失敗',
+    transactionEdited: '交易已更新,帳本已重新計算',
+    editTransactionFailed: '編輯交易失敗',
+    manualPrintCreated: '已新增手動列印費用,帳本已重新計算',
+    manualPrintFailed: '建立手動列印失敗',
+    memberSaved: '成員已儲存',
+    memberSaveFailed: '儲存成員失敗',
+    memberRemoved: '成員已移除',
+    memberRemoveFailed: '移除成員失敗',
+    costCenterNameRequired: '成本中心名稱為必填項',
+    costCenterUpdated: '成本中心已更新',
+    costCenterUpdateFailed: '更新成本中心失敗',
+    confirmDeleteCostCenter: '刪除成本中心 "{{name}}" ?',
+    costCenterDeleted: '成本中心已刪除',
+    costCenterDeleteFailed: '刪除成本中心失敗',
+    deleteTransactionConfirm: '刪除此交易?餘額將自動重新計算。',
+    deposit: '存款',
+    withdraw: '取款',
+    printCharge: '列印費用',
+    deleteTransaction: '刪除交易',
   },
 
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
       printerDeleted: '印表機已刪除',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
+      killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 計費失敗。預算保留已保留;請檢查伺服器記錄。',
       assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
       assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
@@ -2313,6 +2408,21 @@ export default {
     useTls: '使用 TLS',
     enableMetricsEndpoint: '啟用指標端點',
     availableMetrics: '可用指標',
+    billingEnabled: '啟用帳單',
+    billingEnabledDescription: '向用戶收取列印費用並啟用財務功能',
+    printerKillSwitch: '列印機緊急停止開關',
+    printerKillSwitchDescription: '立即停止未經授權的列印',
+    financeBudgetReset: '財務預算月度重置',
+    financeBudgetResetDay: '重置日期',
+    financeBudgetResetDayHelp: '對於少數月份,重置使用該月的最後一天。',
+    financeBudgetResetTimezone: '重置時區',
+    financeBudgetResetTimezoneHelp: '預算視窗的開始時間在此時區計算。',
+    rebuildLedger: '重建錢包帳本',
+    rebuildLedgerStarted: '錢包帳本重建已啟動',
+    rebuildLedgerConfirmTitle: '重建錢包帳本?',
+    rebuildLedgerConfirmMessage:
+      '這將重建錢包帳本以修復歷史餘額值。僅在您知道自己在做什麼時才執行此操作。',
+    rebuildLedgerInProgress: '正在啟動重建...',
     editUser: '編輯使用者',
     deleteUserTitle: '刪除使用者',
     groupName: '群組名稱',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: '總計:{{minutes}} 分鐘',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     gcodeInjection: '注入自動列印G-code',
+    insufficientBudget: '預算不足',
+    unlimitedNoBudget: '無限制 – 未設定預算上限。',
+    noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
   },
 
   // Backup
@@ -5780,6 +5893,8 @@ export default {
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '計費失敗',
+    billingChargeFailedDescription: '無法記錄列印費用時通知',
     missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
     printFailed: '列印失敗',
     printStopped: '列印已停止',

+ 1496 - 0
frontend/src/pages/FinancePage.tsx

@@ -0,0 +1,1496 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Building2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Clock3, Pencil, Trash2, Wallet } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+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';
+import { parseUTCDate } from '../utils/date';
+
+type PartialPrintStatus = 'aborted' | 'failed' | 'cancelled';
+
+interface ParsedPrintChargeDescription {
+  isPartial: boolean;
+  partialType: PartialPrintStatus | null;
+  cleanedDescription: string | null;
+}
+
+const PRINT_CHARGE_PARTIAL_REGEX = /\[(aborted|failed|cancelled):\s*([^\]]*)\]/i;
+
+function parsePrintChargeDescription(description: string | null): ParsedPrintChargeDescription {
+  if (!description) {
+    return { isPartial: false, partialType: null, cleanedDescription: null };
+  }
+
+  const match = description.match(PRINT_CHARGE_PARTIAL_REGEX);
+  if (!match) {
+    return { isPartial: false, partialType: null, cleanedDescription: description };
+  }
+
+  const cleanedDescription = description.replace(match[0], '').trim();
+  return {
+    isPartial: true,
+    partialType: match[1].toLowerCase() as PartialPrintStatus,
+    cleanedDescription: cleanedDescription || null,
+  };
+}
+
+function formatTimestamp(value: string | null, locale: string): string {
+  if (!value) return '-';
+  const parsed = parseUTCDate(value);
+  if (!parsed) return value;
+  return parsed.toLocaleString(locale);
+}
+
+function parseBudgetValue(raw: string): number | null {
+  const trimmed = raw.trim();
+  if (!trimmed) return null;
+  const parsed = Number.parseFloat(trimmed);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
+function formatLocalDateTime(value: Date): string {
+  const year = value.getFullYear();
+  const month = String(value.getMonth() + 1).padStart(2, '0');
+  const day = String(value.getDate()).padStart(2, '0');
+  const hours = String(value.getHours()).padStart(2, '0');
+  const minutes = String(value.getMinutes()).padStart(2, '0');
+  return `${year}-${month}-${day}T${hours}:${minutes}`;
+}
+
+const fieldClass =
+  'w-full px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:outline-none focus:ring-1 focus:ring-bambu-green';
+const labelClass = 'block text-sm font-medium text-white mb-1';
+const tableHeadCellClass = 'px-4 py-3 text-left text-bambu-gray font-medium';
+const tableCellClass = 'px-4 py-3 align-top text-white';
+
+interface FinanceModalProps {
+  title: string;
+  onClose: () => void;
+  children: React.ReactNode;
+  size?: 'sm' | 'md' | 'lg';
+}
+
+function FinanceModal({ title, onClose, children, size = 'md' }: FinanceModalProps) {
+  const { t } = useTranslation();
+  const sizeClass = size === 'sm' ? 'max-w-xl' : size === 'lg' ? 'max-w-6xl' : 'max-w-4xl';
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
+      <div className={`w-full ${sizeClass} overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary`}>
+        <div className="flex items-center justify-between border-b border-bambu-dark-tertiary px-4 py-3">
+          <h3 className="text-lg font-semibold text-white">{title}</h3>
+          <Button size="sm" variant="secondary" onClick={onClose}>{t('common.close', 'Close')}</Button>
+        </div>
+        <div className="max-h-[75vh] overflow-auto p-4">{children}</div>
+      </div>
+    </div>
+  );
+}
+
+export function FinancePage() {
+  const { t, i18n } = useTranslation();
+  const { hasPermission, user } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+
+  const canReadOwn = hasPermission('cost_centers:read_own');
+  const canReadAllFinance = hasPermission('cost_centers:read_all');
+  const canCreateCostCenters = hasPermission('cost_centers:create');
+  const canUpdateCostCenters = hasPermission('cost_centers:modify');
+  const canUpdateBudgets = hasPermission('cost_centers:modify');
+  const canAssignCostCenterUsers = hasPermission('cost_centers:modify');
+  const canAdjustWallet = hasPermission('cost_centers:modify');
+  const canReadUsers = hasPermission('users:read');
+
+  const canAccessAllCostCenters =
+    canReadAllFinance ||
+    canCreateCostCenters ||
+    canUpdateCostCenters ||
+    canUpdateBudgets ||
+    canAssignCostCenterUsers ||
+    canAdjustWallet;
+
+  const canAccessFinance = canReadOwn || canAccessAllCostCenters;
+  const canViewMyCostCenters = canAccessFinance;
+
+  const [newCenterName, setNewCenterName] = useState('');
+  const [newCenterBudgetMode, setNewCenterBudgetMode] = useState<'total' | 'monthly'>('monthly');
+  const [newCenterBudgetValue, setNewCenterBudgetValue] = useState('');
+
+  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
+  const [selectedAdjustmentType, setSelectedAdjustmentType] = useState<'deposit' | 'withdraw'>('deposit');
+  const [adjustmentAmount, setAdjustmentAmount] = useState('');
+  const [adjustmentDescription, setAdjustmentDescription] = useState('');
+  const [adjustmentCostCenterId, setAdjustmentCostCenterId] = useState<number | null>(null);
+
+  const [selectedManageCenterId, setSelectedManageCenterId] = useState<number | null>(null);
+  const [memberUserId, setMemberUserId] = useState<number | null>(null);
+  const [memberCanPrint, setMemberCanPrint] = useState(true);
+
+  const [txOffset, setTxOffset] = useState(0);
+  const txLimit = 50;
+  const [txTypeFilter, setTxTypeFilter] = useState<string>('all');
+  const [txCostCenterFilter, setTxCostCenterFilter] = useState<number | 'all'>('all');
+
+  const [showCreateCenterModal, setShowCreateCenterModal] = useState(false);
+  const [showAdjustWalletModal, setShowAdjustWalletModal] = useState(false);
+  const [showMembersModal, setShowMembersModal] = useState(false);
+  const [showEditCenterModal, setShowEditCenterModal] = useState(false);
+  const [financeViewMode, setFinanceViewMode] = useState<'personal' | 'admin'>('personal');
+  const [selectedEditCenterId, setSelectedEditCenterId] = useState<number | null>(null);
+  const [editCenterName, setEditCenterName] = useState('');
+  const [editCenterBudgetMode, setEditCenterBudgetMode] = useState<'total' | 'monthly'>('monthly');
+  const [editCenterBudgetValue, setEditCenterBudgetValue] = useState('');
+
+  const [showEditTransactionModal, setShowEditTransactionModal] = useState(false);
+  const [selectedEditTransactionId, setSelectedEditTransactionId] = useState<number | null>(null);
+  const [editTransactionUserId, setEditTransactionUserId] = useState<number | null>(null);
+  const [editTransactionCostCenterId, setEditTransactionCostCenterId] = useState<number | null>(null);
+  const [editTransactionAmount, setEditTransactionAmount] = useState('');
+  const [editTransactionDescription, setEditTransactionDescription] = useState('');
+
+  const [showManualPrintModal, setShowManualPrintModal] = useState(false);
+  const [manualPrintUserId, setManualPrintUserId] = useState<number | null>(null);
+  const [manualPrintCostCenterId, setManualPrintCostCenterId] = useState<number | null>(null);
+  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 ||
+    canCreateCostCenters ||
+    canUpdateCostCenters ||
+    canUpdateBudgets ||
+    (canAdjustWallet && canReadUsers) ||
+    (canAssignCostCenterUsers && canReadUsers);
+
+  useEffect(() => {
+    if (!hasAdminFinanceControls) {
+      setFinanceViewMode('personal');
+    }
+  }, [hasAdminFinanceControls]);
+
+  const { data: wallet, isLoading: walletLoading } = useQuery({
+    queryKey: ['finance', 'me', 'balance'],
+    queryFn: api.getMyBalance,
+    enabled: canReadOwn,
+  });
+
+  const { data: transactionsResponse, isLoading: personalTxLoading } = useQuery({
+    queryKey: ['finance', 'me', 'transactions', txLimit, txOffset],
+    queryFn: () => api.getMyTransactions(txLimit, txOffset),
+    enabled: canReadOwn,
+  });
+
+  const { data: adminTransactionsResponse, isLoading: adminTxLoading } = useQuery({
+    queryKey: ['finance', 'transactions', txLimit, txOffset],
+    queryFn: () => api.getAllTransactions(txLimit, txOffset),
+    enabled: financeViewMode === 'admin' && canReadAllFinance,
+  });
+
+  const { data: costCenters, isLoading: centersLoading } = useQuery({
+    queryKey: ['finance', 'cost-centers', financeViewMode],
+    queryFn: () => (financeViewMode === 'admin' && canAccessAllCostCenters ? api.listCostCenters(true) : api.getMyCostCenters()),
+    enabled: canViewMyCostCenters,
+  });
+
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: api.getUsers,
+    enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers),
+  });
+
+  const { data: selectedCenterDetail } = useQuery({
+    queryKey: ['finance', 'cost-center', selectedManageCenterId],
+    queryFn: () => api.getCostCenter(selectedManageCenterId!),
+    enabled: canAssignCostCenterUsers && selectedManageCenterId != null,
+  });
+
+  useEffect(() => {
+    if (!users || users.length === 0) return;
+    if (selectedUserId != null && users.some((u) => u.id === selectedUserId)) return;
+    setSelectedUserId(users[0].id);
+  }, [users, selectedUserId]);
+
+  useEffect(() => {
+    if (!canAssignCostCenterUsers) return;
+    const sharedCenters = (costCenters || []).filter((c) => !c.is_private);
+    if (sharedCenters.length === 0) {
+      setSelectedManageCenterId(null);
+      return;
+    }
+    if (selectedManageCenterId != null && sharedCenters.some((c) => c.id === selectedManageCenterId)) return;
+    setSelectedManageCenterId(sharedCenters[0].id);
+  }, [canAssignCostCenterUsers, costCenters, selectedManageCenterId]);
+
+  useEffect(() => {
+    if (!canAssignCostCenterUsers || !users || users.length === 0) return;
+    const existingMemberIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id));
+    const firstAvailable = users.find((u) => !existingMemberIds.has(u.id));
+    setMemberUserId(firstAvailable ? firstAvailable.id : null);
+  }, [canAssignCostCenterUsers, users, selectedCenterDetail]);
+
+  useEffect(() => {
+    if (!showEditCenterModal) return;
+    const editableCenters = costCenters || [];
+    if (editableCenters.length === 0) {
+      setSelectedEditCenterId(null);
+      return;
+    }
+    if (selectedEditCenterId != null && editableCenters.some((center) => center.id === selectedEditCenterId)) return;
+    setSelectedEditCenterId(editableCenters[0].id);
+  }, [showEditCenterModal, selectedEditCenterId, costCenters]);
+
+  useEffect(() => {
+    if (!showEditCenterModal || selectedEditCenterId == null) return;
+    const center = (costCenters || []).find((entry) => entry.id === selectedEditCenterId);
+    if (!center) return;
+    setEditCenterName(center.name);
+    if (center.budget_mode === 'total') {
+      setEditCenterBudgetMode('total');
+      setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget));
+      return;
+    }
+    setEditCenterBudgetMode('monthly');
+    setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget));
+  }, [showEditCenterModal, selectedEditCenterId, costCenters]);
+
+  const createCostCenterMutation = useMutation({
+    mutationFn: () =>
+      api.createCostCenter({
+        name: newCenterName.trim(),
+        total_budget: newCenterBudgetMode === 'total' ? parseBudgetValue(newCenterBudgetValue) : null,
+        monthly_budget: newCenterBudgetMode === 'monthly' ? parseBudgetValue(newCenterBudgetValue) : null,
+        is_active: true,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      setNewCenterName('');
+      setNewCenterBudgetValue('');
+      showToast(t('finance.createdCostCenter', 'Cost center created'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('finance.createCostCenterFailed', 'Failed to create cost center'), 'error');
+    },
+  });
+
+  const updateBudgetMutation = useMutation({
+    mutationFn: ({ costCenterId, value, mode }: { costCenterId: number; value: string; mode: 'total' | 'monthly' }) =>
+      api.updateCostCenterBudgets(costCenterId, {
+        total_budget: mode === 'total' ? parseBudgetValue(value) : null,
+        monthly_budget: mode === 'monthly' ? parseBudgetValue(value) : null,
+      }),
+  });
+
+  const updateCostCenterMutation = useMutation({
+    mutationFn: ({ costCenterId, name }: { costCenterId: number; name: string }) =>
+      api.updateCostCenter(costCenterId, {
+        name,
+      }),
+  });
+
+  const deleteCostCenterMutation = useMutation({
+    mutationFn: (costCenterId: number) => api.deleteCostCenter(costCenterId),
+  });
+
+  const deleteTransactionMutation = useMutation({
+    mutationFn: (transactionId: number) => api.deleteTransaction(transactionId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      showToast(t('finance.transactionDeleted', 'Transaction deleted'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('finance.deleteTransactionFailed', 'Failed to delete transaction'), 'error');
+    },
+  });
+
+  const editTransactionMutation = useMutation({
+    mutationFn: (payload: { transactionId: number; data: TransactionEditRequest }) =>
+      api.editTransaction(payload.transactionId, payload.data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      showToast(t('finance.transactionEdited', 'Transaction updated and ledger recalculated'));
+      setShowEditTransactionModal(false);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('finance.editTransactionFailed', 'Failed to edit transaction'), 'error');
+    },
+  });
+
+  const manualPrintMutation = useMutation({
+    mutationFn: (data: ManualPrintRequest) => api.createManualPrint(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      showToast(t('finance.manualPrintCreated', 'Manual print charge added and ledger recalculated'));
+      setShowManualPrintModal(false);
+      setManualPrintUserId(null);
+      setManualPrintCostCenterId(null);
+      setManualPrintAmount('');
+      setManualPrintDescription('');
+      setManualPrintDate(formatLocalDateTime(new Date()));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('finance.manualPrintFailed', 'Failed to create manual print'), 'error');
+    },
+  });
+
+  const depositMutation = useMutation({
+    mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) =>
+      api.depositUserBalance(payload.userId, {
+        amount: payload.amount,
+        description: payload.description,
+        cost_center_id: payload.costCenterId ?? null,
+      }),
+  });
+
+  const withdrawMutation = useMutation({
+    mutationFn: (payload: { userId: number; amount: number; description?: string; costCenterId?: number | null }) =>
+      api.withdrawUserBalance(payload.userId, {
+        amount: payload.amount,
+        description: payload.description,
+        cost_center_id: payload.costCenterId ?? null,
+      }),
+  });
+
+  const upsertMemberMutation = useMutation({
+    mutationFn: (payload: { costCenterId: number; userId: number; canPrint: boolean }) =>
+      api.upsertCostCenterMember(payload.costCenterId, {
+        user_id: payload.userId,
+        can_print: payload.canPrint,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      showToast(t('finance.memberSaved', 'Member saved'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('finance.memberSaveFailed', 'Failed to save member'), 'error');
+    },
+  });
+
+  const removeMemberMutation = useMutation({
+    mutationFn: (payload: { costCenterId: number; userId: number }) =>
+      api.removeCostCenterMember(payload.costCenterId, payload.userId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      showToast(t('finance.memberRemoved', 'Member removed'));
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('finance.memberRemoveFailed', 'Failed to remove member'), 'error');
+    },
+  });
+
+  const isAdjustingWallet = depositMutation.isPending || withdrawMutation.isPending;
+
+  const handleCreateCostCenter = () => {
+    if (!newCenterName.trim()) {
+      showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error');
+      return;
+    }
+    createCostCenterMutation.mutate();
+  };
+
+  const handleSaveEditedCenter = async () => {
+    if (selectedEditCenterId == null) {
+      showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
+      return;
+    }
+
+    const name = editCenterName.trim();
+    if (!name) {
+      showToast(t('finance.costCenterNameRequired', 'Cost center name is required'), 'error');
+      return;
+    }
+
+    try {
+      if (canUpdateCostCenters) {
+        await updateCostCenterMutation.mutateAsync({ costCenterId: selectedEditCenterId, name });
+      }
+      if (canUpdateBudgets) {
+        await updateBudgetMutation.mutateAsync({
+          costCenterId: selectedEditCenterId,
+          mode: editCenterBudgetMode,
+          value: editCenterBudgetValue,
+        });
+      }
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      showToast(t('finance.costCenterUpdated', 'Cost center updated'));
+      setShowEditCenterModal(false);
+    } catch (error) {
+      showToast((error as Error).message || t('finance.costCenterUpdateFailed', 'Failed to update cost center'), 'error');
+    }
+  };
+
+  const handleOpenEditCenter = (costCenterId: number) => {
+    const center = (costCenters || []).find((entry) => entry.id === costCenterId);
+    if (!center) return;
+
+    setSelectedEditCenterId(center.id);
+    setEditCenterName(center.name);
+    if (center.budget_mode === 'total') {
+      setEditCenterBudgetMode('total');
+      setEditCenterBudgetValue(center.total_budget == null ? '' : String(center.total_budget));
+    } else {
+      setEditCenterBudgetMode('monthly');
+      setEditCenterBudgetValue(center.monthly_budget == null ? '' : String(center.monthly_budget));
+    }
+    setShowEditCenterModal(true);
+  };
+
+  const confirmDeleteCenter = async () => {
+    if (!pendingDeleteCenter) return;
+    try {
+      await deleteCostCenterMutation.mutateAsync(pendingDeleteCenter.id);
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      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 confirmDeleteTransaction = () => {
+    if (pendingDeleteTransactionId == null) return;
+    deleteTransactionMutation.mutate(pendingDeleteTransactionId, {
+      onSettled: () => setPendingDeleteTransactionId(null),
+    });
+  };
+
+  const handleEditTransaction = (tx: WalletTransaction) => {
+    setSelectedEditTransactionId(tx.id);
+    setEditTransactionUserId(tx.user_id);
+    setEditTransactionCostCenterId(tx.cost_center_id);
+    setEditTransactionAmount(String(tx.amount));
+    setEditTransactionDescription(tx.description || '');
+    setShowEditTransactionModal(true);
+  };
+
+  const handleSaveEditTransaction = async () => {
+    if (selectedEditTransactionId == null) return;
+
+    const amount = editTransactionAmount ? Number.parseFloat(editTransactionAmount) : null;
+    if (amount !== null && !Number.isFinite(amount)) {
+      showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
+      return;
+    }
+
+    editTransactionMutation.mutate({
+      transactionId: selectedEditTransactionId,
+      data: {
+        user_id: editTransactionUserId || undefined,
+        cost_center_id: editTransactionCostCenterId || undefined,
+        amount: amount ?? undefined,
+        description: editTransactionDescription || undefined,
+      },
+    });
+  };
+
+  const handleSaveManualPrint = async () => {
+    if (manualPrintUserId == null) {
+      showToast(t('finance.userRequired', 'Please select a user'), 'error');
+      return;
+    }
+    if (manualPrintCostCenterId == null) {
+      showToast(t('finance.costCenterRequired', 'Please select a cost center'), 'error');
+      return;
+    }
+
+    let amount = manualPrintAmount ? Number.parseFloat(manualPrintAmount) : null;
+    if (amount === null || !Number.isFinite(amount)) {
+      showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
+      return;
+    }
+    amount = Math.abs(amount);
+
+    manualPrintMutation.mutate({
+      user_id: manualPrintUserId,
+      cost_center_id: manualPrintCostCenterId,
+      amount: amount,
+      description: manualPrintDescription || undefined,
+      created_at: manualPrintDate ? new Date(manualPrintDate).toISOString() : undefined,
+    });
+  };
+
+  const handleWalletAdjustment = async () => {
+    if (selectedUserId == null) {
+      showToast(t('finance.userRequired', 'Please select a user'), 'error');
+      return;
+    }
+
+    const amount = Number.parseFloat(adjustmentAmount);
+    if (!Number.isFinite(amount) || amount <= 0) {
+      showToast(t('finance.amountMustBePositive', 'Amount must be greater than zero'), 'error');
+      return;
+    }
+
+    const payload = {
+      userId: selectedUserId,
+      amount,
+      description: adjustmentDescription.trim() || undefined,
+      costCenterId: adjustmentCostCenterId,
+    };
+
+    try {
+      if (selectedAdjustmentType === 'deposit') {
+        await depositMutation.mutateAsync(payload);
+      } else {
+        await withdrawMutation.mutateAsync(payload);
+      }
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      setAdjustmentAmount('');
+      setAdjustmentDescription('');
+      showToast(
+        selectedAdjustmentType === 'deposit'
+          ? t('finance.depositSuccess', 'Deposit successful')
+          : t('finance.withdrawSuccess', 'Withdrawal successful')
+      );
+    } catch (error) {
+      showToast((error as Error).message || t('finance.adjustmentFailed', 'Wallet adjustment failed'), 'error');
+    }
+  };
+
+  const handleAddMember = () => {
+    if (selectedManageCenterId == null) {
+      showToast(t('finance.selectCostCenter', 'Please select a cost center'), 'error');
+      return;
+    }
+    if (memberUserId == null) {
+      showToast(t('finance.noEligibleUsers', 'No eligible users available'), 'error');
+      return;
+    }
+    upsertMemberMutation.mutate({
+      costCenterId: selectedManageCenterId,
+      userId: memberUserId,
+      canPrint: memberCanPrint,
+    });
+  };
+
+  const handleRemoveMember = (userId: number) => {
+    if (selectedManageCenterId == null) return;
+    removeMemberMutation.mutate({ costCenterId: selectedManageCenterId, userId });
+  };
+
+  const currency = wallet?.currency || 'EUR';
+  const currencySymbol = getCurrencySymbol(currency);
+
+  const sortedUsers = useMemo(() => {
+    return [...(users || [])].sort((a, b) => a.username.localeCompare(b.username));
+  }, [users]);
+
+  const usersById = useMemo(() => {
+    const map = new Map<number, string>();
+    for (const entry of sortedUsers) {
+      map.set(entry.id, entry.username);
+    }
+    return map;
+  }, [sortedUsers]);
+
+  const availableUsersForCenter = useMemo(() => {
+    const existingIds = new Set((selectedCenterDetail?.members || []).map((m) => m.user_id));
+    return sortedUsers.filter((u) => !existingIds.has(u.id));
+  }, [sortedUsers, selectedCenterDetail]);
+
+  const activeTransactionsResponse =
+    financeViewMode === 'admin' && canReadAllFinance
+      ? adminTransactionsResponse
+      : transactionsResponse;
+
+  const txLoading = (financeViewMode === 'admin' && canReadAllFinance)
+    ? adminTxLoading
+    : personalTxLoading;
+
+  const transactions = useMemo(
+    () => activeTransactionsResponse?.items ?? [],
+    [activeTransactionsResponse?.items]
+  );
+  const txTotal = activeTransactionsResponse?.total ?? 0;
+  const txTotalPages = Math.max(1, Math.ceil(txTotal / txLimit));
+
+  const filteredTransactions = useMemo(() => {
+    const items = transactions;
+    return items.filter((tx) => {
+      if (txTypeFilter !== 'all' && tx.transaction_type !== txTypeFilter) return false;
+      if (txCostCenterFilter !== 'all' && tx.cost_center_id !== txCostCenterFilter) return false;
+      return true;
+    });
+  }, [transactions, txTypeFilter, txCostCenterFilter]);
+
+  const txPage = Math.floor(txOffset / txLimit) + 1;
+  const showCostCenterAccountColumn = financeViewMode === 'admin';
+
+  const getTransactionTypeLabel = (transactionType: string): string => {
+    if (transactionType === 'deposit') return t('finance.deposit');
+    if (transactionType === 'withdraw') return t('finance.withdraw');
+    if (transactionType === 'print_charge') return t('finance.printCharge');
+    return transactionType;
+  };
+
+  const getPrivateOwnerLabel = (ownerUserId: number | null): string => {
+    if (ownerUserId == null) return t('finance.personal', 'Personal');
+    const ownerName = usersById.get(ownerUserId);
+    if (ownerName) return ownerName;
+    if (user?.id === ownerUserId && user.username) return user.username;
+    return t('finance.userWithId', 'User #{{id}}', { id: ownerUserId });
+  };
+
+  const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => {
+    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)}`;
+  };
+
+  if (!canAccessFinance) {
+    return (
+      <div className="p-4 md:p-8 space-y-6">
+        <div className="flex items-center gap-2">
+          <Wallet className="h-6 w-6 text-bambu-green" />
+          <h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
+        </div>
+        <div>
+          <p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
+        </div>
+        <Card>
+          <CardContent className="py-8 text-center text-bambu-gray">
+            {t('finance.noAccess', 'You do not have permission to view finance data.')}
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-4 md:p-8 space-y-8">
+      <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
+        <div>
+          <div className="flex items-center gap-2">
+            <Wallet className="h-6 w-6 text-bambu-green" />
+            <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">
+          {hasAdminFinanceControls && (
+            <div className="inline-flex overflow-hidden rounded border border-bambu-dark-tertiary">
+              <button
+                type="button"
+                onClick={() => setFinanceViewMode('personal')}
+                className={`px-3 py-1.5 text-sm ${financeViewMode === 'personal' ? 'bg-bambu-green text-black font-medium' : 'bg-bambu-dark text-bambu-gray'}`}
+              >
+                {t('finance.personalView', 'Personal view')}
+              </button>
+              <button
+                type="button"
+                onClick={() => setFinanceViewMode('admin')}
+                className={`px-3 py-1.5 text-sm ${financeViewMode === 'admin' ? 'bg-bambu-green text-black font-medium' : 'bg-bambu-dark text-bambu-gray'}`}
+              >
+                {t('finance.adminView', 'Admin view')}
+              </button>
+            </div>
+          )}
+
+          {financeViewMode === 'admin' && hasAdminFinanceControls && (
+            <div className="flex flex-wrap gap-2 lg:justify-end">
+            {canCreateCostCenters && (
+              <Button size="sm" variant="secondary" onClick={() => setShowCreateCenterModal(true)}>
+                {t('finance.createCostCenter', 'Create cost center')}
+              </Button>
+            )}
+            {canAdjustWallet && canReadUsers && (
+              <Button size="sm" variant="secondary" onClick={() => setShowAdjustWalletModal(true)}>
+                {t('finance.adjustWallet', 'Adjust wallet')}
+              </Button>
+            )}
+            {canAdjustWallet && canReadUsers && (
+              <Button size="sm" variant="secondary" onClick={() => setShowManualPrintModal(true)}>
+                {t('finance.addManualPrint', 'Add manual print')}
+              </Button>
+            )}
+            {canAssignCostCenterUsers && canReadUsers && (
+              <Button size="sm" variant="secondary" onClick={() => setShowMembersModal(true)}>
+                {t('finance.manageMembers', 'Manage cost center members')}
+              </Button>
+            )}
+            </div>
+          )}
+        </div>
+      </div>
+
+      <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 account balance')}</span>
+            <Wallet className="w-4 h-4 text-bambu-green/90" />
+          </CardHeader>
+          <CardContent className="pt-1">
+            <p className="text-3xl font-semibold text-white leading-tight">
+              {walletLoading ? t('common.loading', 'Loading...') : `${currencySymbol}${(wallet?.balance ?? 0).toFixed(2)}`}
+            </p>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <span className="text-sm text-bambu-gray">
+              {financeViewMode === 'admin'
+                ? t('finance.transactions', 'Transactions')
+                : t('finance.personalTransactions', 'Personal transactions')}
+            </span>
+            <Clock3 className="w-4 h-4 text-blue-400" />
+          </CardHeader>
+          <CardContent className="pt-1">
+            <p className="text-3xl font-semibold text-white leading-tight">{txLoading ? '-' : txTotal}</p>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <span className="text-sm text-bambu-gray">{t('finance.costCenters', 'Cost centers')}</span>
+            <Building2 className="w-4 h-4 text-orange-400" />
+          </CardHeader>
+          <CardContent className="pt-1">
+            <p className="text-3xl font-semibold text-white leading-tight">{centersLoading ? '-' : costCenters?.length ?? 0}</p>
+          </CardContent>
+        </Card>
+      </div>
+
+      {showCreateCenterModal && canCreateCostCenters && (
+        <FinanceModal title={t('finance.createCostCenter', 'Create cost center')} size="md" onClose={() => setShowCreateCenterModal(false)}>
+          <div className="space-y-4">
+            <div className="grid gap-4 md:grid-cols-3">
+              <div>
+                <label className={labelClass}>{t('finance.costCenterName', 'Name')}</label>
+                <input
+                  type="text"
+                  value={newCenterName}
+                  onChange={(e) => setNewCenterName(e.target.value)}
+                  placeholder={t('finance.costCenterName', 'Name')}
+                  className={fieldClass}
+                />
+              </div>
+              <div>
+                <label className={labelClass}>{t('finance.budgetType', 'Budget type')}</label>
+                <select
+                  value={newCenterBudgetMode}
+                  onChange={(e) => setNewCenterBudgetMode(e.target.value as 'total' | 'monthly')}
+                  className={fieldClass}
+                >
+                  <option value="monthly">{t('finance.monthlyBudget', 'Monthly budget')}</option>
+                  <option value="total">{t('finance.totalBudget', 'Total budget')}</option>
+                </select>
+              </div>
+              <div>
+                <label className={labelClass}>
+                  {newCenterBudgetMode === 'monthly' ? t('finance.monthlyBudget', 'Monthly budget') : t('finance.totalBudget', 'Total budget')}
+                </label>
+                <input
+                  type="number"
+                  step="0.01"
+                  value={newCenterBudgetValue}
+                  onChange={(e) => setNewCenterBudgetValue(e.target.value)}
+                  placeholder="0.00"
+                  className={fieldClass}
+                />
+              </div>
+            </div>
+            <Button className="min-w-[180px]" onClick={handleCreateCostCenter} disabled={createCostCenterMutation.isPending}>
+              {createCostCenterMutation.isPending ? t('common.saving', 'Saving...') : t('finance.create', 'Create')}
+            </Button>
+          </div>
+        </FinanceModal>
+      )}
+
+      {showAdjustWalletModal && canAdjustWallet && canReadUsers && (
+        <FinanceModal title={t('finance.adjustWallet', 'Adjust wallet')} size="md" onClose={() => setShowAdjustWalletModal(false)}>
+          <div className="space-y-4">
+            <div className="grid gap-4 md:grid-cols-2">
+              <div>
+                <label className={labelClass}>{t('finance.selectUser', 'Select user')}</label>
+                <select
+                  value={selectedUserId ?? ''}
+                  onChange={(e) => setSelectedUserId(e.target.value ? Number(e.target.value) : null)}
+                  className={fieldClass}
+                >
+                  {(sortedUsers || []).map((u) => (
+                    <option key={u.id} value={u.id}>{u.username}</option>
+                  ))}
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.transactionType', 'Type')}</label>
+                <select
+                  value={selectedAdjustmentType}
+                  onChange={(e) => setSelectedAdjustmentType(e.target.value as 'deposit' | 'withdraw')}
+                  className={fieldClass}
+                >
+                  <option value="deposit">{t('finance.deposit', 'Deposit')}</option>
+                  <option value="withdraw">{t('finance.withdraw', 'Withdraw')}</option>
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
+                <input
+                  type="number"
+                  step="0.01"
+                  min="0"
+                  value={adjustmentAmount}
+                  onChange={(e) => setAdjustmentAmount(e.target.value)}
+                  placeholder="0.00"
+                  className={fieldClass}
+                />
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
+                <select
+                  value={adjustmentCostCenterId ?? ''}
+                  onChange={(e) => setAdjustmentCostCenterId(e.target.value ? Number(e.target.value) : null)}
+                  className={fieldClass}
+                >
+                  <option value="">{t('finance.noCostCenter', 'No cost center')}</option>
+                  {(costCenters || []).map((center) => (
+                    <option key={center.id} value={center.id}>{center.name}</option>
+                  ))}
+                </select>
+              </div>
+            </div>
+
+            <div>
+              <label className={labelClass}>{t('common.description', 'Description')}</label>
+              <input
+                type="text"
+                value={adjustmentDescription}
+                onChange={(e) => setAdjustmentDescription(e.target.value)}
+                placeholder={t('finance.descriptionOptional', 'Description (optional)')}
+                className={fieldClass}
+              />
+            </div>
+
+            <Button className="min-w-[180px]" onClick={handleWalletAdjustment} disabled={isAdjustingWallet}>
+              {isAdjustingWallet ? t('common.saving', 'Saving...') : t('finance.applyAdjustment', 'Apply adjustment')}
+            </Button>
+          </div>
+        </FinanceModal>
+      )}
+
+      {showMembersModal && canAssignCostCenterUsers && canReadUsers && (
+        <FinanceModal title={t('finance.manageMembers', 'Manage cost center members')} size="lg" onClose={() => setShowMembersModal(false)}>
+          <div className="space-y-5">
+            <div className="grid gap-5 md:grid-cols-2">
+              <div>
+                <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
+                <select
+                  value={selectedManageCenterId ?? ''}
+                  onChange={(e) => setSelectedManageCenterId(e.target.value ? Number(e.target.value) : null)}
+                  className={fieldClass}
+                >
+                  {(costCenters || [])
+                    .filter((center) => !center.is_private)
+                    .map((center) => (
+                      <option key={center.id} value={center.id}>{center.name}</option>
+                    ))}
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.selectUser', 'Select user')}</label>
+                <select
+                  value={memberUserId ?? ''}
+                  onChange={(e) => setMemberUserId(e.target.value ? Number(e.target.value) : null)}
+                  className={fieldClass}
+                >
+                  <option value="">{t('finance.selectUser', 'Select user')}</option>
+                  {availableUsersForCenter.map((u) => (
+                    <option key={u.id} value={u.id}>{u.username}</option>
+                  ))}
+                </select>
+              </div>
+
+              <div className="flex items-center gap-2 rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary px-3 py-2">
+                <input
+                  id="memberCanPrint"
+                  type="checkbox"
+                  checked={memberCanPrint}
+                  onChange={(e) => setMemberCanPrint(e.target.checked)}
+                  className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                />
+                <label htmlFor="memberCanPrint" className="text-sm text-bambu-gray">
+                  {t('finance.memberCanPrint', 'Member can print')}
+                </label>
+              </div>
+
+              <div className="flex items-end">
+                <Button className="min-w-[180px]" onClick={handleAddMember} disabled={upsertMemberMutation.isPending || memberUserId == null}>
+                  {upsertMemberMutation.isPending ? t('common.saving', 'Saving...') : t('finance.addMember', 'Add member')}
+                </Button>
+              </div>
+            </div>
+
+            <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
+              <table className="w-full text-sm">
+                <thead>
+                  <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
+                    <th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
+                    <th className={tableHeadCellClass}>{t('finance.canPrint', 'Can print')}</th>
+                    <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {(selectedCenterDetail?.members || []).map((member) => {
+                    const user = sortedUsers.find((u) => u.id === member.user_id);
+                    return (
+                      <tr key={member.id} className="border-b border-bambu-dark-tertiary/60 text-white">
+                        <td className={tableCellClass}>{user?.username || `User ${member.user_id}`}</td>
+                        <td className={tableCellClass}>{member.can_print ? t('common.yes', 'Yes') : t('common.no', 'No')}</td>
+                        <td className={tableCellClass}>
+                          <Button
+                            size="sm"
+                            variant="danger"
+                            onClick={() => handleRemoveMember(member.user_id)}
+                            disabled={removeMemberMutation.isPending}
+                          >
+                            {t('common.remove', 'Remove')}
+                          </Button>
+                        </td>
+                      </tr>
+                    );
+                  })}
+                  {(selectedCenterDetail?.members || []).length === 0 && (
+                    <tr>
+                      <td colSpan={3} className="py-3 text-bambu-gray">
+                        {t('finance.noMembers', 'No members assigned.')}
+                      </td>
+                    </tr>
+                  )}
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </FinanceModal>
+      )}
+
+      {showEditCenterModal && (canUpdateCostCenters || canUpdateBudgets) && (
+        <FinanceModal title={t('finance.editCostCenter', 'Edit cost center')} size="md" onClose={() => setShowEditCenterModal(false)}>
+          <div className="space-y-4">
+            <div>
+              <label className={labelClass}>{t('finance.costCenterName', 'Name')}</label>
+              <input
+                type="text"
+                value={editCenterName}
+                onChange={(e) => setEditCenterName(e.target.value)}
+                placeholder={t('finance.costCenterName', 'Name')}
+                className={fieldClass}
+                disabled={!canUpdateCostCenters}
+              />
+            </div>
+
+            <div className="grid gap-4 md:grid-cols-2">
+              <div>
+                <label className={labelClass}>{t('finance.budgetType', 'Budget type')}</label>
+                <select
+                  value={editCenterBudgetMode}
+                  onChange={(e) => setEditCenterBudgetMode(e.target.value as 'total' | 'monthly')}
+                  className={fieldClass}
+                  disabled={!canUpdateBudgets}
+                >
+                  <option value="monthly">{t('finance.monthlyBudget', 'Monthly budget')}</option>
+                  <option value="total">{t('finance.totalBudget', 'Total budget')}</option>
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>
+                  {editCenterBudgetMode === 'monthly' ? t('finance.monthlyBudget', 'Monthly budget') : t('finance.totalBudget', 'Total budget')}
+                </label>
+                <input
+                  type="number"
+                  step="0.01"
+                  value={editCenterBudgetValue}
+                  onChange={(e) => setEditCenterBudgetValue(e.target.value)}
+                  placeholder="0.00"
+                  className={fieldClass}
+                  disabled={!canUpdateBudgets}
+                />
+              </div>
+            </div>
+
+            <Button
+              className="min-w-[180px]"
+              onClick={handleSaveEditedCenter}
+              disabled={updateCostCenterMutation.isPending || updateBudgetMutation.isPending || selectedEditCenterId == null}
+            >
+              {(updateCostCenterMutation.isPending || updateBudgetMutation.isPending)
+                ? t('common.saving', 'Saving...')
+                : t('common.save', 'Save')}
+            </Button>
+          </div>
+        </FinanceModal>
+      )}
+
+      <div className={`grid gap-6 ${canViewMyCostCenters && canReadOwn ? 'xl:grid-cols-2' : ''}`}>
+        {canViewMyCostCenters && (
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">{t('finance.myCostCenters', 'My cost centers')}</h2>
+              <p className="text-sm text-bambu-gray mt-1">{t('finance.costCentersHint', 'Review budget limits and keep costs under control')}</p>
+            </CardHeader>
+            <CardContent className="space-y-3">
+              {centersLoading && <p className="text-sm text-bambu-gray">{t('common.loading', 'Loading...')}</p>}
+              {!centersLoading && (!costCenters || costCenters.length === 0) && (
+                <p className="text-sm text-bambu-gray">{t('finance.noCostCenters', 'No cost centers found.')}</p>
+              )}
+              {!centersLoading && costCenters && costCenters.length > 0 && (
+                <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
+                  <table className="w-full text-sm">
+                    <thead>
+                      <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', 'Account balance')}</th>
+                          <th className={tableHeadCellClass}>{t('finance.budget', 'Budget')}</th>
+                          {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {costCenters.map((center) => {
+                        const canEditRow = canUpdateCostCenters || canUpdateBudgets;
+                        const canDeleteRow = !center.is_private && canUpdateCostCenters;
+                        return (
+                          <tr key={center.id} className="border-b border-bambu-dark-tertiary/60 text-white">
+                              <td className={tableCellClass}>{center.name}</td>
+                              {showCostCenterAccountColumn && (
+                                <td className={tableCellClass}>{center.is_private ? getPrivateOwnerLabel(center.owner_user_id) : t('finance.shared', 'Shared')}</td>
+                              )}
+                              <td className={tableCellClass}>{currencySymbol}{center.total_balance.toFixed(2)}</td>
+                              <td className={tableCellClass}>
+                              <div className="flex flex-col gap-0.5">
+                                <span>{formatBudgetProgress(center)}</span>
+                                <span className="text-xs text-bambu-gray">
+                                  {center.budget_mode === 'monthly'
+                                    ? t('finance.monthlyBudget', 'Monthly budget')
+                                    : center.budget_mode === 'total'
+                                      ? t('finance.totalBudget', 'Total budget')
+                                      : t('finance.noBudget', 'No budget')}
+                                </span>
+                              </div>
+                            </td>
+                              {showCostCenterAccountColumn && (
+                                <td className={tableCellClass}>
+                                  <div className="flex items-center gap-2">
+                                    <Button
+                                      size="sm"
+                                      variant="ghost"
+                                      onClick={() => handleOpenEditCenter(center.id)}
+                                      disabled={!canEditRow}
+                                      title={canEditRow ? t('common.edit', 'Edit') : t('finance.cannotEditPrivateCostCenter', 'Private cost centers cannot be edited here')}
+                                      className="p-1.5 sm:p-2"
+                                    >
+                                      <Pencil className="w-4 h-4" />
+                                    </Button>
+                                    {canDeleteRow && (
+                                      <Button
+                                        size="sm"
+                                        variant="ghost"
+                                        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"
+                                      >
+                                        <Trash2 className="w-4 h-4" />
+                                      </Button>
+                                    )}
+                                  </div>
+                                </td>
+                              )}
+                          </tr>
+                        );
+                      })}
+                    </tbody>
+                  </table>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        )}
+
+        {canReadOwn && (
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">{t('finance.recentTransactions', 'Recent transactions')}</h2>
+              <p className="text-sm text-bambu-gray mt-1">{t('finance.transactionsHint', 'Filter by type and cost center, then navigate pages')}</p>
+            </CardHeader>
+            <CardContent className="space-y-4">
+            <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] items-end">
+              <div>
+                <label className={labelClass}>{t('finance.transactionType', 'Type')}</label>
+                <select
+                  value={txTypeFilter}
+                  onChange={(e) => setTxTypeFilter(e.target.value)}
+                  className={fieldClass}
+                >
+                  <option value="all">{t('finance.allTypes')}</option>
+                  <option value="deposit">{t('finance.deposit')}</option>
+                  <option value="withdraw">{t('finance.withdraw')}</option>
+                  <option value="print_charge">{t('finance.printCharge')}</option>
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.costCenters', 'Cost centers')}</label>
+                <select
+                  value={txCostCenterFilter}
+                  onChange={(e) => setTxCostCenterFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
+                  className={fieldClass}
+                >
+                  <option value="all">{t('finance.allCostCenters')}</option>
+                  {(costCenters || []).map((center) => (
+                    <option key={center.id} value={center.id}>{center.name}</option>
+                  ))}
+                </select>
+              </div>
+
+            </div>
+
+            {txLoading && <p className="text-sm text-bambu-gray">{t('common.loading', 'Loading...')}</p>}
+            {!txLoading && (!transactions || transactions.length === 0) && (
+              <p className="text-sm text-bambu-gray">{t('finance.noTransactions', 'No transactions available.')}</p>
+            )}
+            {!txLoading && transactions.length > 0 && filteredTransactions.length === 0 && (
+              <p className="text-sm text-bambu-gray">{t('finance.noTransactionsForFilter', 'No transactions match the selected filters.')}</p>
+            )}
+            {!txLoading && filteredTransactions.length > 0 && (
+              <div className="overflow-auto rounded-lg border border-bambu-dark-tertiary">
+                <table className="w-full text-sm">
+                  <thead>
+                    <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
+                        <th className={tableHeadCellClass}>{t('common.date', 'Date')}</th>
+                        {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.user', 'User')}</th>}
+                        <th className={tableHeadCellClass}>{t('finance.costCenter', 'Cost center')}</th>
+                        <th className={tableHeadCellClass}>{t('finance.transactionType', 'Type')}</th>
+                        <th className={tableHeadCellClass}>{t('common.description', 'Description')}</th>
+                        <th className={tableHeadCellClass}>{t('finance.amount', 'Amount')}</th>
+                        <th className={tableHeadCellClass}>{t('finance.balanceAfter', 'Balance after')}</th>
+                        {showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
+                    </tr>
+                  </thead>
+                  <tbody>
+                    {filteredTransactions.map((tx) => {
+                      const positive = tx.amount >= 0;
+                      const txCostCenter = tx.cost_center_id == null
+                        ? null
+                        : (costCenters || []).find((center) => center.id === tx.cost_center_id);
+                      const txUser = usersById.get(tx.user_id);
+                      const parsed = tx.transaction_type === 'print_charge'
+                        ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)
+                        : null;
+                      const txIsPartialPrint = parsed?.isPartial ?? false;
+
+                      return (
+                        <tr key={tx.id} className="border-b border-bambu-dark-tertiary/60 text-white">
+                            <td className={tableCellClass}>{formatTimestamp(tx.created_at, i18n.language)}</td>
+                            {showCostCenterAccountColumn && (
+                              <td className={tableCellClass}>{txUser || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id })}</td>
+                            )}
+                            <td className={tableCellClass}>{txCostCenter?.name || '-'}</td>
+                            <td className={tableCellClass}>
+                              <span className="inline-flex items-center gap-2">
+                                <span>{getTransactionTypeLabel(tx.transaction_type)}</span>
+                                {txIsPartialPrint && (
+                                  <span className="rounded border border-yellow-500/40 bg-yellow-500/10 px-2 py-0.5 text-[11px] text-yellow-300">
+                                    {parsed && parsed.partialType
+                                      ? t(`finance.partialStatus.${parsed.partialType}`, parsed.partialType)
+                                      : t('finance.partial', 'Partial')}
+                                  </span>
+                                )}
+                              </span>
+                            </td>
+                            <td className={tableCellClass}>{(parsed?.cleanedDescription ?? tx.description) || '-'}</td>
+                            <td className={`${tableCellClass} ${positive ? 'text-green-400' : 'text-red-400'}`}>
+                            {positive ? '+' : '-'}{currencySymbol}{Math.abs(tx.amount).toFixed(2)}
+                          </td>
+                            <td className={tableCellClass}>{
+                              tx.balance_after == null
+                                ? '-'
+                                : `${tx.balance_after < 0 ? '-' : ''}${currencySymbol}${Math.abs(tx.balance_after).toFixed(2)}`
+                            }</td>
+                            {showCostCenterAccountColumn && (
+                              <td className={tableCellClass}>
+                                <div className="flex items-center gap-2">
+                                  <Button
+                                    size="sm"
+                                    variant="secondary"
+                                    onClick={() => handleEditTransaction(tx)}
+                                    disabled={editTransactionMutation.isPending}
+                                  >
+                                    <Pencil className="w-4 h-4" />
+                                  </Button>
+                                  <Button
+                                    size="sm"
+                                    variant="danger"
+                                    onClick={() => setPendingDeleteTransactionId(tx.id)}
+                                    disabled={deleteTransactionMutation.isPending}
+                                  >
+                                    <Trash2 className="w-4 h-4" />
+                                  </Button>
+                                </div>
+                              </td>
+                            )}
+                        </tr>
+                      );
+                    })}
+                  </tbody>
+                </table>
+              </div>
+            )}
+
+            {!txLoading && filteredTransactions.length > 0 && (
+              <div className="flex justify-end pt-2 lg:pt-0">
+                <div className="flex flex-wrap items-center justify-end gap-2">
+                  <button
+                    type="button"
+                    onClick={() => setTxOffset(0)}
+                    disabled={txOffset === 0 || txLoading}
+                    className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                    aria-label={t('finance.first', 'First')}
+                  >
+                    <ChevronsLeft className="w-4 h-4" />
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => setTxOffset((prev) => Math.max(0, prev - txLimit))}
+                    disabled={txOffset === 0 || txLoading}
+                    className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                    aria-label={t('finance.prev', 'Previous')}
+                  >
+                    <ChevronLeft className="w-4 h-4" />
+                  </button>
+                  <span className="text-bambu-gray px-1 whitespace-nowrap">
+                    {t('finance.pageNumberOf', 'Page {{page}} of {{total}}', { page: txPage, total: txTotalPages })}
+                  </span>
+                  <button
+                    type="button"
+                    onClick={() => setTxOffset((prev) => prev + txLimit)}
+                    disabled={txLoading || txPage >= txTotalPages}
+                    className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                    aria-label={t('finance.next', 'Next')}
+                  >
+                    <ChevronRight className="w-4 h-4" />
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => setTxOffset(Math.max(0, (txTotalPages - 1) * txLimit))}
+                    disabled={txLoading || txPage >= txTotalPages}
+                    className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                    aria-label={t('finance.last', 'Last')}
+                  >
+                    <ChevronsRight className="w-4 h-4" />
+                  </button>
+                </div>
+              </div>
+            )}
+            </CardContent>
+          </Card>
+        )}
+
+        {showEditTransactionModal && selectedEditTransactionId !== null && (
+          <FinanceModal
+            title={t('finance.editTransaction', 'Edit Transaction')}
+            onClose={() => setShowEditTransactionModal(false)}
+            size="md"
+          >
+            <div className="space-y-4">
+              <div>
+                <label className={labelClass}>{t('common.user', 'User')}</label>
+                <select
+                  className={fieldClass}
+                  value={editTransactionUserId || ''}
+                  onChange={(e) => setEditTransactionUserId(e.target.value ? Number(e.target.value) : null)}
+                >
+                  <option value="">{t('finance.selectUser', 'Select user...')}</option>
+                  {sortedUsers.map((u) => (
+                    <option key={u.id} value={u.id}>{u.username}</option>
+                  ))}
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.costCenter', 'Cost center (optional)')}</label>
+                <select
+                  className={fieldClass}
+                  value={editTransactionCostCenterId || ''}
+                  onChange={(e) => setEditTransactionCostCenterId(e.target.value ? Number(e.target.value) : null)}
+                >
+                  <option value="">{t('finance.noCostCenter', 'Personal (no cost center)')}</option>
+                  {(costCenters || []).map((cc) => (
+                    <option key={cc.id} value={cc.id}>{cc.name}</option>
+                  ))}
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
+                <input
+                  type="number"
+                  step="0.01"
+                  className={fieldClass}
+                  value={editTransactionAmount}
+                  onChange={(e) => setEditTransactionAmount(e.target.value)}
+                  placeholder={t('finance.amountExample', 'e.g., 10.50')}
+                />
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('common.description', 'Description')}</label>
+                <textarea
+                  className={`${fieldClass} resize-none`}
+                  rows={3}
+                  value={editTransactionDescription}
+                  onChange={(e) => setEditTransactionDescription(e.target.value)}
+                  placeholder={t('finance.manualAdjustmentExample', 'e.g., Manual adjustment')}
+                />
+              </div>
+
+              <div className="flex justify-end gap-2 pt-4">
+                <Button
+                  variant="secondary"
+                  onClick={() => setShowEditTransactionModal(false)}
+                >
+                  {t('common.cancel', 'Cancel')}
+                </Button>
+                <Button
+                  onClick={handleSaveEditTransaction}
+                  disabled={editTransactionMutation.isPending}
+                >
+                  {t('common.save', 'Save')}
+                </Button>
+              </div>
+            </div>
+          </FinanceModal>
+        )}
+
+        {showManualPrintModal && (
+          <FinanceModal
+            title={t('finance.addManualPrint', 'Add manual print')}
+            onClose={() => setShowManualPrintModal(false)}
+            size="md"
+          >
+            <div className="space-y-4">
+              <div>
+                <label className={labelClass}>{t('common.user', 'User')}</label>
+                <select
+                  className={fieldClass}
+                  value={manualPrintUserId || ''}
+                  onChange={(e) => setManualPrintUserId(e.target.value ? Number(e.target.value) : null)}
+                >
+                  <option value="">{t('finance.selectUser', 'Select user...')}</option>
+                  {sortedUsers.map((u) => (
+                    <option key={u.id} value={u.id}>{u.username}</option>
+                  ))}
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.costCenter', 'Cost center')}</label>
+                <select
+                  className={fieldClass}
+                  value={manualPrintCostCenterId || ''}
+                  onChange={(e) => setManualPrintCostCenterId(e.target.value ? Number(e.target.value) : null)}
+                >
+                  <option value="">{t('finance.selectCostCenter', 'Select cost center...')}</option>
+                  {(costCenters || []).map((cc) => (
+                    <option key={cc.id} value={cc.id}>{cc.name}</option>
+                  ))}
+                </select>
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('finance.amount', 'Amount')}</label>
+                <input
+                  type="number"
+                  step="0.01"
+                  className={fieldClass}
+                  value={manualPrintAmount}
+                  onChange={(e) => setManualPrintAmount(e.target.value)}
+                  placeholder={t('finance.amountExample', 'e.g., 4.00')}
+                />
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('common.description', 'Description')}</label>
+                <input
+                  type="text"
+                  value={manualPrintDescription}
+                  onChange={(e) => setManualPrintDescription(e.target.value)}
+                  placeholder={t('finance.descriptionOptional', 'Description (optional)')}
+                  className={fieldClass}
+                />
+              </div>
+
+              <div>
+                <label className={labelClass}>{t('common.date', 'Date')}</label>
+                <input
+                  type="datetime-local"
+                  value={manualPrintDate}
+                  onChange={(e) => setManualPrintDate(e.target.value)}
+                  className={fieldClass}
+                />
+              </div>
+
+              <div className="flex justify-end gap-2 pt-4">
+                <Button variant="secondary" onClick={() => setShowManualPrintModal(false)}>
+                  {t('common.cancel', 'Cancel')}
+                </Button>
+                <Button onClick={handleSaveManualPrint} disabled={manualPrintMutation.isPending}>
+                  {manualPrintMutation.isPending ? t('common.saving', 'Saving...') : t('finance.addManualPrint', 'Add manual print')}
+                </Button>
+              </div>
+            </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>
+  );
+}

+ 156 - 1
frontend/src/pages/SettingsPage.tsx

@@ -193,6 +193,8 @@ export function SettingsPage() {
   const [templateFilter, setTemplateFilter] = useState('');
   const [settingsSearch, setSettingsSearch] = useState('');
   const [showLogViewer, setShowLogViewer] = useState(false);
+  const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
+  const [isRebuildLoading, setIsRebuildLoading] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
 
   // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
@@ -1085,7 +1087,11 @@ export function SettingsPage() {
       (baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
       (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
       (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
-      (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
+      (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24) ||
+      (baseline.billing_enabled ?? false) !== (localSettings.billing_enabled ?? false) ||
+      (baseline.printer_kill_switch_enabled ?? false) !== (localSettings.printer_kill_switch_enabled ?? false) ||
+      (baseline.finance_budget_reset_day ?? 1) !== (localSettings.finance_budget_reset_day ?? 1) ||
+      (baseline.finance_budget_reset_timezone ?? 'UTC') !== (localSettings.finance_budget_reset_timezone ?? 'UTC');
 
     if (!hasChanges) {
       return;
@@ -1190,6 +1196,10 @@ export function SettingsPage() {
         chamber_temp_presets: localSettings.chamber_temp_presets,
         fan_speed_presets: localSettings.fan_speed_presets,
         session_max_hours: localSettings.session_max_hours,
+        billing_enabled: localSettings.billing_enabled,
+        printer_kill_switch_enabled: localSettings.printer_kill_switch_enabled,
+        finance_budget_reset_day: localSettings.finance_budget_reset_day,
+        finance_budget_reset_timezone: localSettings.finance_budget_reset_timezone,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -2309,6 +2319,151 @@ export function SettingsPage() {
                     : t('settings.energyModeTotalDescription')}
                 </p>
               </div>
+              <div>
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white">{t('settings.billingEnabled')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.billingEnabledDescription')}</p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.billing_enabled ?? false}
+                      onChange={(e) => updateSetting('billing_enabled', e.target.checked)}
+                      className="peer sr-only"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
+                  </label>
+                </div>
+                {localSettings.billing_enabled && (
+                  <div className="mt-4 flex items-center justify-between gap-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark px-4 py-3">
+                    <div className="flex-1">
+                      <p className="text-white">
+                        {t('settings.printerKillSwitch', 'Unauthorized print kill switch')}
+                      </p>
+                      <p className="text-xs text-bambu-gray mt-1">
+                        {t(
+                          'settings.printerKillSwitchDescription',
+                          'Immediately stop prints that start without authorization.'
+                        )}
+                      </p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={localSettings.printer_kill_switch_enabled ?? false}
+                        onChange={(e) => updateSetting('printer_kill_switch_enabled', e.target.checked)}
+                        className="peer sr-only"
+                      />
+                      <div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
+                    </label>
+                  </div>
+                )}
+                {localSettings.billing_enabled && (
+                  <div className="mt-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-4">
+                    <div className="mb-3">
+                      <h4 className="text-base font-medium text-white flex items-center gap-2">
+                        <Calendar className="w-4 h-4 text-bambu-green" />
+                        {t('settings.financeBudgetReset', 'Finance Monthly Budget Reset')}
+                      </h4>
+                    </div>
+                    <div className="flex gap-4">
+                      <div className="flex-1">
+                        <label className="block text-xs text-bambu-gray mb-1">
+                          {t('settings.financeBudgetResetDay', 'Reset Day')}
+                        </label>
+                        <input
+                          type="number"
+                          min={1}
+                          max={31}
+                          value={localSettings.finance_budget_reset_day ?? 1}
+                          onChange={(e) =>
+                            updateSetting(
+                              'finance_budget_reset_day',
+                              Math.max(1, Math.min(31, parseInt(e.target.value, 10) || 1))
+                            )
+                          }
+                          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                        />
+                        <p className="text-xs text-bambu-gray mt-1">
+                          {t('settings.financeBudgetResetDayHelp', 'For short months, reset uses the last day of the month.')}
+                        </p>
+                      </div>
+                      <div className="flex-1">
+                        <label className="block text-xs text-bambu-gray mb-1">
+                          {t('settings.financeBudgetResetTimezone', 'Reset timezone')}
+                        </label>
+                        <select
+                          value={localSettings.finance_budget_reset_timezone ?? 'UTC'}
+                          onChange={(e) => updateSetting('finance_budget_reset_timezone', e.target.value)}
+                          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                        >
+                          <option value="UTC">UTC</option>
+                          <option value="Europe/Berlin">Europe/Berlin</option>
+                          <option value="Europe/Vienna">Europe/Vienna</option>
+                          <option value="Europe/Zurich">Europe/Zurich</option>
+                          <option value="America/New_York">America/New_York</option>
+                          <option value="America/Chicago">America/Chicago</option>
+                          <option value="America/Denver">America/Denver</option>
+                          <option value="America/Los_Angeles">America/Los_Angeles</option>
+                          <option value="Asia/Tokyo">Asia/Tokyo</option>
+                          <option value="Asia/Singapore">Asia/Singapore</option>
+                          <option value="Australia/Sydney">Australia/Sydney</option>
+                        </select>
+                        <p className="text-xs text-bambu-gray mt-1">
+                          {t('settings.financeBudgetResetTimezoneHelp', 'Budget window start is calculated in this timezone.')}
+                        </p>
+                      </div>
+                    </div>
+                  </div>
+                )}
+                {localSettings.billing_enabled && isAdmin && (
+                  <div className="mt-3">
+                    <Button
+                      variant="secondary"
+                      onClick={() => setShowRebuildConfirm(true)}
+                      disabled={isRebuildLoading}
+                    >
+                      {isRebuildLoading ? (
+                        <>
+                          <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                          {t('settings.rebuildLedgerInProgress', 'Starting...')}
+                        </>
+                      ) : (
+                        t('settings.rebuildLedger', 'Rebuild wallet ledger')
+                      )}
+                    </Button>
+
+                    {showRebuildConfirm && (
+                      <ConfirmModal
+                        title={t('settings.rebuildLedgerConfirmTitle', 'Rebuild wallet ledger?')}
+                        message={t(
+                          'settings.rebuildLedgerConfirmMessage',
+                          'This will rebuild the wallet ledger to repair historical balance values. Run this only if you know what you are doing.'
+                        )}
+                        confirmText={t('common.run', 'Run')}
+                        cancelText={t('common.cancel', 'Cancel')}
+                        isLoading={isRebuildLoading}
+                        loadingText={t('common.running', 'Running')}
+                        variant="warning"
+                        onCancel={() => setShowRebuildConfirm(false)}
+                        onConfirm={async () => {
+                          setIsRebuildLoading(true);
+                          try {
+                            await api.rebuildBalanceLedger();
+                            showToast(t('settings.rebuildLedgerStarted', 'Ledger rebuild started'), 'success');
+                            setShowRebuildConfirm(false);
+                          } catch (err) {
+                            showToast((err as Error).message || 'Failed to start ledger rebuild', 'error');
+                          } finally {
+                            setIsRebuildLoading(false);
+                          }
+                        }}
+                      />
+                    )}
+                  </div>
+                )}
+              </div>
             </CardContent>
           </Card>