Forráskód Böngészése

Merge pull request #1448 from behrinml/feature/billing

[Feature] Cost Centers and Billing Functionality
MartinNYHC 1 hónapja
szülő
commit
3cd538acb9
77 módosított fájl, 11183 hozzáadás és 90 törlés
  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,
     save_smtp_settings,
     send_email,
     send_email,
 )
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 
 _logger = logging.getLogger(__name__)
 _logger = logging.getLogger(__name__)
 
 
@@ -480,6 +481,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
                     if user and ldap_user:
                     if user and ldap_user:
                         # Update email and group mappings on each login
                         # Update email and group mappings on each login
                         await _sync_ldap_user(db, user, ldap_user, ldap_config)
                         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)
         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
             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())
         new_user.groups = list(groups_result.scalars().all())
 
 
     db.add(new_user)
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.commit()
     await db.refresh(new_user)
     await db.refresh(new_user)
     logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)
     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_failed": "Print Failed",
     "print_stopped": "Print Stopped",
     "print_stopped": "Print Stopped",
     "print_progress": "Print Progress",
     "print_progress": "Print Progress",
+    "billing_charge_failed": "Billing Charge Failed",
     "printer_offline": "Printer Offline",
     "printer_offline": "Printer Offline",
     "printer_error": "Printer Error",
     "printer_error": "Printer Error",
     "filament_low": "Filament Low",
     "filament_low": "Filament Low",

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

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

+ 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_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 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.notification_service import notification_service
 from backend.app.services.print_batch import (
 from backend.app.services.print_batch import (
     BatchDispatchError,
     BatchDispatchError,
@@ -50,6 +51,7 @@ from backend.app.services.print_batch import (
     load_progress,
     load_progress,
     refresh_batch_status,
     refresh_batch_status,
 )
 )
+from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.utils.printer_models import (
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
     is_gcode_compatible,
 )
 )
@@ -278,6 +280,55 @@ async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path |
     return None
     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:
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -333,6 +384,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "waiting_reason": item.waiting_reason,
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
         "library_file_id": item.library_file_id,
+        "cost_center_id": item.cost_center_id,
+        "estimated_cost": item.estimated_cost,
         "position": item.position,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
         "require_previous_success": item.require_previous_success,
@@ -942,6 +995,43 @@ async def add_to_queue(
         if not project_result.scalar_one_or_none():
         if not project_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Project not found")
             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
     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
     # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
     # per-slot filament-mapping edit was made), but the archive carries the
     # 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,
             filament_overrides=filament_overrides_json,
             archive_id=data.archive_id,
             archive_id=data.archive_id,
             library_file_id=data.library_file_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,
             scheduled_time=data.scheduled_time,
             require_previous_success=data.require_previous_success,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
             auto_off_after=data.auto_off_after,
@@ -1133,6 +1225,7 @@ async def bulk_update_queue_items(
 
 
     updated_count = 0
     updated_count = 0
     skipped_count = 0
     skipped_count = 0
+    validates_billing_fields = "cost_center_id" in update_data or "estimated_cost" in update_data
 
 
     for item in items:
     for item in items:
         # Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
         # 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
             skipped_count += 1
             continue
             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)
             setattr(item, field, value)
         updated_count += 1
         updated_count += 1
 
 
@@ -1552,6 +1663,12 @@ async def cancel_batch(
     cancelled_count = 0
     cancelled_count = 0
     for item in pending_items:
     for item in pending_items:
         item.status = "cancelled"
         item.status = "cancelled"
+        await release_budget_reservation(
+            db,
+            source_type="print_queue",
+            source_id=item.id,
+            status="released",
+        )
         cancelled_count += 1
         cancelled_count += 1
 
 
     batch.status = "cancelled"
     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
             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
     # Re-check the dispatch claim right before mutating (#2615). Several awaited
     # validations ran since the guard above, and a scheduler worker may have
     # 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
     # 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":
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently 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.delete(item)
     await db.commit()
     await db.commit()
 
 
@@ -1956,6 +2096,12 @@ async def cancel_queue_item(
 
 
     item.status = "cancelled"
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
     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()
     await db.commit()
 
 
     logger.info("Cancelled queue item %s", item_id)
     logger.info("Cancelled queue item %s", item_id)
@@ -2120,6 +2266,22 @@ async def start_queue_item(
     if item.status != "pending":
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
         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
     # Live deficit check — re-evaluated against current spool state, so a
     # spool swap between scheduler flagging and the user clicking ▶ clears
     # spool swap between scheduler flagging and the user clicking ▶ clears
     # the block automatically.
     # 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_vibration_cali",
             "default_layer_inspect",
             "default_layer_inspect",
             "default_timelapse",
             "default_timelapse",
+            "billing_enabled",
+            "printer_kill_switch_enabled",
             "ldap_enabled",
             "ldap_enabled",
             "ldap_auto_provision",
             "ldap_auto_provision",
             "local_login_enabled",
             "local_login_enabled",
@@ -221,6 +223,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_group_size",
             "stagger_interval_minutes",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
             "forecast_global_lead_time_days",
+            "finance_budget_reset_day",
             "session_max_hours",
             "session_max_hours",
             "pipeline_max_copies",
             "pipeline_max_copies",
             "preheat_max_wait_seconds",
             "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,
     get_smtp_settings,
     send_email,
     send_email,
 )
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 
 router = APIRouter(prefix="/users", tags=["users"])
 router = APIRouter(prefix="/users", tags=["users"])
 
 
@@ -164,6 +165,8 @@ async def create_user(
         new_user.groups = list(groups)
         new_user.groups = list(groups)
 
 
     db.add(new_user)
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.commit()
     await db.refresh(new_user)
     await db.refresh(new_user)
 
 
@@ -307,6 +310,8 @@ async def update_user(
             )
             )
         user.groups = list(groups)
         user.groups = list(groups)
 
 
+    await ensure_user_finance_defaults(db, user)
+
     await db.commit()
     await db.commit()
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one()
     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_UPDATE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_READ,
         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.
         # GitHub backup admin + firmware OTA.
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_RESTORE,
         Permission.GITHUB_RESTORE,

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

@@ -261,6 +261,7 @@ async def init_db():
         external_link,
         external_link,
         filament,
         filament,
         filament_sku_settings,
         filament_sku_settings,
+        finance,
         github_backup,
         github_backup,
         group,
         group,
         kprofile_note,
         kprofile_note,
@@ -1024,6 +1025,219 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
         raise
         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):
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
     """Run all schema migrations and data backfills on startup.
 
 
@@ -1038,6 +1252,11 @@ async def run_migrations(conn):
     """
     """
     from sqlalchemy import text
     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).
     # 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
     # 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.
     # "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
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
     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
     # 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)")
     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
     # 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")
     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
     # Migration: Add custom_interval_type column to printer_maintenance
     await _safe_execute(conn, "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")
     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_enabled BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")
     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
     # Migration: Add missing-spool-assignment print-start notification toggle
     try:
     try:
         async with conn.begin_nested():
         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)",
         "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)
     # 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)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     if is_sqlite():
     if is_sqlite():
@@ -1308,6 +1581,20 @@ async def run_migrations(conn):
     # Migration: Add manual_start column to print_queue for staged prints
     # 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")
     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
     # 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)")
     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'"))
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             row = result.fetchone()
             if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
             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(
                 await conn.execute(
                     text("""
                     text("""
                     CREATE TABLE print_queue_new (
                     CREATE TABLE print_queue_new (
                         id INTEGER PRIMARY KEY,
                         id INTEGER PRIMARY KEY,
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER NOT NULL REFERENCES print_archives(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,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
                         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
                     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,
                            manual_start, require_previous_success, auto_off_after, ams_mapping,
                            status, started_at, completed_at, error_message, created_at
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                     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("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
         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'"))
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             row = result.fetchone()
             if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
             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(
                 await conn.execute(
                     text("""
                     text("""
                     CREATE TABLE print_queue_new2 (
                     CREATE TABLE print_queue_new2 (
@@ -1660,6 +1963,7 @@ async def run_migrations(conn):
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER REFERENCES print_archives(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,
                         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,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
                         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
                     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,
                            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(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
                            COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
                            COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
                            status, started_at, completed_at, error_message, created_at
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                     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("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
         except (OperationalError, ProgrammingError):
@@ -2649,6 +2966,39 @@ async def run_migrations(conn):
     # Migration: Auto-print G-code injection (#422)
     # Migration: Auto-print G-code injection (#422)
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
     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
     # 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_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives 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",
         "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:
     async with async_session() as session:
         # Get existing groups
         # Get existing groups
         result = await session.execute(select(Group))
         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
                                 "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
                     # For Administrators, also ensure they get *_all permissions if they have any new *_own
                     if group_name == "Administrators":
                     if group_name == "Administrators":
                         for _own_perm, all_perm in [
                         for _own_perm, all_perm in [
@@ -4522,3 +4894,93 @@ async def seed_color_catalog():
             )
             )
         await session.commit()
         await session.commit()
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
+
+
+async def repair_wallet_ledger_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_READ = "stats:read"
     STATS_FILTER_BY_USER = "stats:filter_by_user"
     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 Info
     SYSTEM_READ = "system:read"
     SYSTEM_READ = "system:read"
 
 
@@ -305,6 +311,12 @@ PERMISSION_CATEGORIES = {
         Permission.STATS_READ,
         Permission.STATS_READ,
         Permission.STATS_FILTER_BY_USER,
         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": [
     "System": [
         Permission.SYSTEM_READ,
         Permission.SYSTEM_READ,
     ],
     ],
@@ -460,6 +472,8 @@ DEFAULT_GROUPS = {
             Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.STATS_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SYSTEM_READ.value,
+            # Finance - own visibility
+            Permission.COST_CENTERS_READ_OWN.value,
             # Settings - read only
             # Settings - read only
             Permission.SETTINGS_READ.value,
             Permission.SETTINGS_READ.value,
             # Slicer Pipelines - full access
             # Slicer Pipelines - full access

+ 459 - 61
backend/app/main.py

@@ -30,6 +30,7 @@ from backend.app.api.routes import (
     discovery,
     discovery,
     external_links,
     external_links,
     filaments,
     filaments,
+    finance,
     firmware,
     firmware,
     github_backup,
     github_backup,
     groups,
     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.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.obico_detection import obico_detection_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.print_scheduler import scheduler as print_scheduler
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     init_printer_connections,
     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
 # Used by usage tracker to map 3MF slots to physical AMS trays
 _print_ams_mappings: dict[int, list[int]] = {}
 _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}
 # 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).
 # 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
 # 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
 # Track whether first layer complete notification has been sent for current print
 _first_layer_notified: dict[int, bool] = {}
 _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}
 # Track HMS errors that have been notified: {printer_id: set of error codes}
 # This prevents sending duplicate notifications for the same error
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
 _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_PRINT_CLEANUP_INTERVAL: int = 15 * 60  # 15 minutes
 _expected_prints_cleanup_task: asyncio.Task | None = None
 _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:
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
     """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
@@ -716,6 +919,7 @@ def register_expected_print(
     archive_id: int,
     archive_id: int,
     ams_mapping: list[int] | None = None,
     ams_mapping: list[int] | None = None,
     created_by_id: int | None = None,
     created_by_id: int | None = None,
+    cost_center_id: int | None = None,
     plate_id: int | None = None,
     plate_id: int | None = None,
 ):
 ):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     """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
     # Store AMS mapping for usage tracking at print completion
     if ams_mapping is not None:
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
         _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
     # 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
     # 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).
     # 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
     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:
 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."""
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     stored_ams_mapping = data.get("ams_mapping")
     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}"
         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)
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
     try:
     try:
         printer_info = printer_manager.get_printer(printer_id)
         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
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
     _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
     # #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.
     # 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):
 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
     bambu_mqtt.py suppresses ``on_print_start`` on the first RUNNING push
     after Bambuddy startup (#1304 guard, prevents duplicate archive
     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
     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
     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__)
     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:
     async with async_session() as db:
         from backend.app.models.printer import Printer
         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))
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
         if not printer:
         if not printer:
@@ -4210,6 +4460,15 @@ async def on_print_running_observed(printer_id: int, data: dict):
             )
             )
             return
             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)
     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)
     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,
     # Drop the 3MF download cache for this printer (#972). The print is over,
     # nothing else legitimately needs the bytes; keeping them would only risk
     # 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.
     # 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.
     # so queue items don't get stuck in "printing" when archive lookup fails.
     # Uses run_with_retry to handle SQLite "database is locked" errors (#897).
     # Uses run_with_retry to handle SQLite "database is locked" errors (#897).
     queue_item_id = None
     queue_item_id = None
+    billing_run_id: str | None = None
+    billing_user_id: int | None = None
+    billing_cost_center_id: int | None = None
+    billing_plate_id: int | None = None
     queue_status = None
     queue_status = None
     queue_auto_off = False
     queue_auto_off = False
     try:
     try:
@@ -5065,6 +5333,7 @@ async def on_print_complete(printer_id: int, data: dict):
         from backend.app.models.print_queue import PrintQueueItem
         from backend.app.models.print_queue import PrintQueueItem
 
 
         async def _update_queue_status(db):
         async def _update_queue_status(db):
+            nonlocal billing_run_id, billing_user_id, billing_cost_center_id, billing_plate_id
             nonlocal queue_item_id, queue_status, queue_auto_off
             nonlocal queue_item_id, queue_status, queue_auto_off
             result = await db.execute(
             result = await db.execute(
                 select(PrintQueueItem)
                 select(PrintQueueItem)
@@ -5097,6 +5366,10 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
                 await db.commit()
                 await db.commit()
                 queue_item_id = item.id
                 queue_item_id = item.id
+                billing_run_id = item.billing_run_id
+                billing_user_id = item.created_by_id
+                billing_cost_center_id = item.cost_center_id
+                billing_plate_id = item.plate_id
                 queue_auto_off = item.auto_off_after
                 queue_auto_off = item.auto_off_after
                 logger.info("Updated queue item %s status to %s", item.id, queue_status)
                 logger.info("Updated queue item %s status to %s", item.id, queue_status)
 
 
@@ -5205,6 +5478,29 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
         except Exception as e:
             logger.warning("[BED-COOL] Failed to register waiter: %s", 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
     # --- Track filament consumption (must run before archive_id early-return so usage
     # is recorded even when auto-archive is disabled) ---
     # is recorded even when auto-archive is disabled) ---
     usage_results: list[dict] = []
     usage_results: list[dict] = []
@@ -5346,9 +5642,12 @@ async def on_print_complete(printer_id: int, data: dict):
                     logger.info(
                     logger.info(
                         "[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps
                         "[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
                     # Send user-specific email if we have a created_by_id
                     if no_archive_data and no_archive_data.get("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")
     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)
     # Write independent print log entry (separate table, never touches archives)
     try:
     try:
         async with async_session() as db:
         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_grams = _compute_run_filament_grams(
                     _run_status,
                     _run_status,
                     _est_grams,
                     _est_grams,
-                    data.get("progress"),
+                    data.get("last_progress", data.get("progress")),
                     usage_results,
                     usage_results,
                 )
                 )
 
 
@@ -5984,9 +6377,12 @@ async def on_print_complete(printer_id: int, data: dict):
                             except Exception as e:
                             except Exception as e:
                                 logger.warning("[NOTIFY-BG] Failed to read finish photo bytes: %s", 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
                 # Send user-specific email notification
                 if archive_data:
                 if archive_data:
@@ -6907,6 +7303,7 @@ def _evict_stale_expected_prints() -> None:
     for archive_id in evicted_archive_ids:
     for archive_id in evicted_archive_ids:
         if archive_id not in live_archive_ids:
         if archive_id not in live_archive_ids:
             _print_ams_mappings.pop(archive_id, None)
             _print_ams_mappings.pop(archive_id, None)
+            _print_cost_center_ids.pop(archive_id, None)
             _print_plate_ids.pop(archive_id, None)
             _print_plate_ids.pop(archive_id, None)
 
 
     logging.getLogger(__name__).info(
     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(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.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(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(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(labels.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)
 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(
     library_file_id: Mapped[int | None] = mapped_column(
         ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
         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
     # File info
     filename: Mapped[str] = mapped_column(String(255))
     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
     # if the same subtask_id reappears after restart, we know it's the same
     # print and keep the original row instead of cancel-then-create.
     # print and keep the original row instead of cancel-then-create.
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    # Durable Bambuddy UUID for billing idempotency. Unlike subtask_id, this is
+    # not constrained by printer firmware and is replaced for every reprint.
+    billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
 
 
     # Which plate of a multi-plate 3MF this print was for (1-based), copied from
     # Which plate of a multi-plate 3MF this print was for (1-based), copied from
     # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
     # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
@@ -92,6 +98,7 @@ class PrintArchive(Base):
 
 
     # User additions
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
     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)
     tags: Mapped[str | None] = mapped_column(Text)
     notes: Mapped[str | None] = mapped_column(Text)
     notes: Mapped[str | None] = mapped_column(Text)
     cost: Mapped[float | None] = mapped_column(Float)
     cost: Mapped[float | None] = mapped_column(Float)
@@ -122,9 +129,11 @@ class PrintArchive(Base):
     # Relationships
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     project: Mapped["Project | 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()
     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.printer import Printer  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.user import User  # 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_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
     on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
     on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
+    on_billing_charge_failed = Column(Boolean, default=True)  # A completed/stopped print could not be charged
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
     on_printer_offline = Column(Boolean, default=False)

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

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

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

@@ -1,6 +1,6 @@
 from datetime import datetime
 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 sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -32,6 +32,13 @@ class PrintQueueItem(Base):
     library_file_id: Mapped[int | None] = mapped_column(
     library_file_id: Mapped[int | None] = mapped_column(
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
         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)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
     batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
 
 
@@ -162,6 +169,7 @@ class PrintQueueItem(Base):
     printer: Mapped["Printer"] = relationship()
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
+    cost_center: Mapped["CostCenter | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
     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.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.library import LibraryFile  # noqa: E402
 from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.printer import Printer  # 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,
         default=False,
         description="Notify when a print starts with required trays missing spool assignments",
         description="Notify when a print starts with required trays missing spool assignments",
     )
     )
+    on_billing_charge_failed: bool = Field(default=True, description="Notify when a print charge cannot be recorded")
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
@@ -137,6 +138,7 @@ class NotificationProviderUpdate(BaseModel):
     on_print_stopped: bool | None = None
     on_print_stopped: bool | None = None
     on_print_progress: bool | None = None
     on_print_progress: bool | None = None
     on_print_missing_spool_assignment: bool | None = None
     on_print_missing_spool_assignment: bool | None = None
+    on_billing_charge_failed: bool | None = None
 
 
     # Event triggers - printer status
     # Event triggers - printer status
     on_printer_offline: bool | None = None
     on_printer_offline: bool | None = None

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

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

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

@@ -114,6 +114,8 @@ class PrintQueueItemCreate(BaseModel):
     batch_id: int | None = None
     batch_id: int | None = None
     # Project to associate the resulting archive with
     # Project to associate the resulting archive with
     project_id: int | None = None
     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
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
     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)
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     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
     # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
     # physical nozzle position IDs from BambuStudio's project_file MQTT
     # physical nozzle position IDs from BambuStudio's project_file MQTT
     # body; sent back to the printer verbatim on dispatch.
     # 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
     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)
     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
     library_file_id: int | None  # For queue items from library files
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     position: int
     position: int
     scheduled_time: UTCDatetime
     scheduled_time: UTCDatetime
     require_previous_success: bool
     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)
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
 
 
 
 
 class PrintQueueBulkUpdateResponse(BaseModel):
 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"
         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
     # Plate-clear confirmation for queue scheduling
     require_plate_clear: bool = Field(
     require_plate_clear: bool = Field(
         default=False,
         default=False,
@@ -635,6 +655,10 @@ class AppSettingsUpdate(BaseModel):
     default_nozzle_offset_cali: TriState | None = None
     default_nozzle_offset_cali: TriState | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     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
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
     queue_shortest_first: bool | None = None
     queue_max_concurrent_uploads: int | None = Field(default=None, ge=1, le=16)
     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,
         created_by_id: int | None = None,
         original_filename: str | None = None,
         original_filename: str | None = None,
         project_id: int | None = None,
         project_id: int | None = None,
+        cost_center_id: int | None = None,
         subtask_id: str | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
         plate_id: int | None = None,
@@ -1354,6 +1355,7 @@ class ArchiveService:
             created_by_id=created_by_id,
             created_by_id=created_by_id,
             project_id=project_id,
             project_id=project_id,
             library_file_id=library_file_id,
             library_file_id=library_file_id,
+            cost_center_id=cost_center_id,
             subtask_id=subtask_id,
             subtask_id=subtask_id,
             plate_id=plate_id,
             plate_id=plate_id,
         )
         )

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

@@ -3207,11 +3207,15 @@ class BambuMQTTClient:
         if "subtask_id" in data:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
         if "mc_percent" in data:
-            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
-            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
             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
             # #2547: strictly-increasing only. The firmware resets progress to 0
             # on cancel and re-reports the same percent on most frames; neither
             # 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
             # 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,
             variables=variables,
         )
         )
 
 
+    async def on_billing_charge_failed(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        archive_id: int | None,
+        error: str,
+        db: AsyncSession,
+    ) -> None:
+        """Notify providers that a terminal print could not be charged."""
+        providers = await self._get_providers_for_event(db, "on_billing_charge_failed", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "archive_id": str(archive_id) if archive_id is not None else "Unknown",
+            "error": error,
+        }
+        title, message = await self._build_message_from_template(db, "billing_charge_failed", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "billing_charge_failed",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)

+ 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 json
 import logging
 import logging
 import time
 import time
+import uuid
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
+from fastapi import HTTPException
 from sqlalchemy import func, select, update
 from sqlalchemy import func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 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.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 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.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.notification_service import notification_service
 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 (
 from backend.app.services.printer_manager import (
     printer_manager,
     printer_manager,
     supports_airduct,
     supports_airduct,
@@ -521,6 +529,11 @@ class PrintScheduler:
         # sequential caller, callbacks on the same loop, so no lock.
         # sequential caller, callbacks on the same loop, so no lock.
         # item_id -> (printer_id, remote_filename, archive_id)
         # item_id -> (printer_id, remote_filename, archive_id)
         self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
         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):
     async def run(self):
         """Main loop - check queue every interval."""
         """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
                 # A confirmed send removes the entry itself, so this is a no-op
                 # on the happy path.
                 # on the happy path.
                 self._rollback_unconfirmed_expected_print(item_id)
                 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
                 # Release the claim on every exit. Once dispatch has finished the
                 # row's status carries the lock (printing/failed/cancelled are all
                 # row's status carries the lock (printing/failed/cancelled are all
                 # != pending), so the token is only needed for the duration of the
                 # != pending), so the token is only needed for the duration of the
@@ -1375,6 +1393,38 @@ class PrintScheduler:
                 exc_info=True,
                 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:
     async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
 
 
@@ -3835,6 +3885,57 @@ class PrintScheduler:
         """
         """
         logger.info("Starting queue item %s", item.id)
         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)
         # Get printer first (needed for both paths)
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
@@ -3955,11 +4056,14 @@ class PrintScheduler:
                     original_filename=filename,
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     created_by_id=item.created_by_id,
                     project_id=item.project_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)
                     library_file_id=item.library_file_id,  # per-file project progress (#1897)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 )
                 if archive:
                 if archive:
                     item.archive_id = archive.id
                     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:
                     if item.cleanup_library_after_dispatch and not library_file.is_external:
                         item.library_file_id = None
                         item.library_file_id = None
                         cleanup_disk_paths.append(file_path)
                         cleanup_disk_paths.append(file_path)
@@ -4275,6 +4379,7 @@ class PrintScheduler:
                 archive.id,
                 archive.id,
                 ams_mapping=ams_mapping,
                 ams_mapping=ams_mapping,
                 created_by_id=item.created_by_id,
                 created_by_id=item.created_by_id,
+                cost_center_id=item.cost_center_id,
                 plate_id=item.plate_id,
                 plate_id=item.plate_id,
             )
             )
             # Registration happens before the print command by necessity (the
             # 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
         # rowcount==0 means the user won the race; bail out, best-effort delete
         # the file we just uploaded, do NOT send start_print.
         # the file we just uploaded, do NOT send start_print.
         now_utc = datetime.now(timezone.utc)
         now_utc = datetime.now(timezone.utc)
+        billing_run_id = str(uuid.uuid4())
         cas = await db.execute(
         cas = await db.execute(
             update(PrintQueueItem)
             update(PrintQueueItem)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.status == "pending")
             .where(PrintQueueItem.status == "pending")
-            .values(status="printing", started_at=now_utc)
+            .values(status="printing", started_at=now_utc, billing_run_id=billing_run_id)
         )
         )
         await db.commit()
         await db.commit()
         if cas.rowcount == 0:
         if cas.rowcount == 0:
@@ -4347,6 +4453,16 @@ class PrintScheduler:
         # item.started_at sees the values we just persisted.
         # item.started_at sees the values we just persisted.
         item.status = "printing"
         item.status = "printing"
         item.started_at = now_utc
         item.started_at = now_utc
+        item.billing_run_id = billing_run_id
+        if archive is not None:
+            archive.billing_run_id = billing_run_id
+            # Legacy transaction deletion used an archive-wide skip flag.
+            # A newly dispatched run has its own UUID/tombstone, so it must be
+            # billable independently of any older deleted run on this archive.
+            archive.wallet_charge_skipped = False
+            # Persist before MQTT send so completion and restart recovery can
+            # always recover the internal billing identity.
+            await db.commit()
 
 
         for cleanup_path in cleanup_disk_paths:
         for cleanup_path in cleanup_disk_paths:
             try:
             try:
@@ -4411,6 +4527,7 @@ class PrintScheduler:
             # survive. Anything still in this dict when _dispatch_one exits gets
             # survive. Anything still in this dict when _dispatch_one exits gets
             # rolled back.
             # rolled back.
             self._unconfirmed_expected_print.pop(item.id, None)
             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)
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # status='processing' from upload start until the printer acked
             # 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."
                         f"prompt or error, confirm its SD card is readable, and start the job again."
                     )
                     )
                 item.completed_at = datetime.now(timezone.utc)
                 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()
                 await db.commit()
                 return "gave_up"
                 return "gave_up"
             item.status = "pending"
             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_stopped": True,
             "on_print_progress": False,
             "on_print_progress": False,
             "on_print_missing_spool_assignment": False,
             "on_print_missing_spool_assignment": False,
+            "on_billing_charge_failed": True,
             "on_printer_offline": False,
             "on_printer_offline": False,
             "on_printer_error": False,
             "on_printer_error": False,
             "on_filament_low": False,
             "on_filament_low": False,

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

@@ -134,6 +134,10 @@ class TestApiKeyDenylistIntegrity:
             Permission.API_KEYS_CREATE,
             Permission.API_KEYS_CREATE,
             Permission.API_KEYS_UPDATE,
             Permission.API_KEYS_UPDATE,
             Permission.API_KEYS_DELETE,
             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_BACKUP,
             Permission.GITHUB_RESTORE,
             Permission.GITHUB_RESTORE,
             Permission.FIRMWARE_UPDATE,
             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
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.ldap_service import LDAPSearchResult, LDAPUserInfo
 from backend.app.services.ldap_service import LDAPSearchResult, LDAPUserInfo
@@ -367,3 +369,95 @@ class TestLdapProvisionRoute:
         body = response.json()
         body = response.json()
         group_names = {g["name"] for g in body["groups"]}
         group_names = {g["name"] for g in body["groups"]}
         assert "Operators" in group_names
         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}")
         response = await async_client.get(f"/api/v1/notifications/{provider.id}")
         assert response.json()["on_print_missing_spool_assignment"] is True
         assert response.json()["on_print_missing_spool_assignment"] is True
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_billing_charge_failed_toggle(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Billing alerts can be enabled independently for each provider."""
+        provider = await notification_provider_factory(on_billing_charge_failed=True)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_billing_charge_failed": False},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_billing_charge_failed"] is False
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_billing_charge_failed"] is False
+
 
 
 class TestNotificationTemplatesAPI:
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""
     """Integration tests for /api/v1/notification-templates/ endpoints."""

+ 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)
         # User cancels (goes to IDLE)
         client._process_message(
         client._process_message(
             {
             {
@@ -409,6 +413,7 @@ class TestTimelapseTracking:
 
 
         assert completion_data["status"] == "aborted"
         assert completion_data["status"] == "aborted"
         assert "hms_errors" in completion_data
         assert "hms_errors" in completion_data
+        assert completion_data["last_progress"] == 25
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_timelapse_detected_from_ipcam_data(self):
     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
 import pytest
 from httpx import AsyncClient
 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:
 class TestPrintQueueAPI:
@@ -125,6 +138,176 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["status"] == "pending"
         assert result["manual_start"] is False
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_manual_start(
     async def test_add_to_queue_with_manual_start(
@@ -181,6 +364,30 @@ class TestPrintQueueAPI:
         result = response.json()
         result = response.json()
         assert result["skip_filament_check"] is False
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_project_id(
     async def test_add_to_queue_with_project_id(
@@ -634,6 +841,54 @@ class TestPrintQueueAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["message"] == "Queue item deleted"
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
     async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
@@ -760,6 +1015,24 @@ class TestQueueStartEndpoint:
         result = response.json()
         result = response.json()
         assert result["manual_start"] is False
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_start_queue_item_not_found(self, async_client: AsyncClient):
     async def test_start_queue_item_not_found(self, async_client: AsyncClient):
@@ -1460,6 +1733,58 @@ class TestBulkUpdateEndpoint:
         assert response.status_code == 400
         assert response.status_code == 400
         assert "printer not found" in response.json()["detail"].lower()
         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:
 class TestTargetLocationFeature:
     """Tests for queue items with target_location (Issue #220)."""
     """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 == []
         assert events == []
 
 
 
 
+class TestBillingProgressTracking:
+    """The latest positive MQTT progress is the fallback for partial billing."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_current_frame_is_retained_and_zero_reset_does_not_overwrite_it(self, mqtt_client):
+        """Even the first positive frame must survive an immediate printer abort."""
+        mqtt_client._process_message({"print": {"mc_percent": 25}})
+
+        assert mqtt_client._last_valid_progress == 25
+
+        mqtt_client._process_message({"print": {"mc_percent": 0}})
+
+        assert mqtt_client._last_valid_progress == 25
+
+
 class TestPrintProgressCallback:
 class TestPrintProgressCallback:
     """#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
     """#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
 
 

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

+ 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,
         original_filename,
         created_by_id=None,
         created_by_id=None,
         project_id=None,
         project_id=None,
+        cost_center_id=None,
         plate_id=None,
         plate_id=None,
         library_file_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
     """If on_print_start already ran in this Bambuddy process for the same
     printer (the realistic same-session race), a second capture would
     printer (the realistic same-session race), a second capture would
     overwrite the correct pre-print baseline with one taken later — which
     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"}
     _timelapse_baselines[1] = {"pre_existing_a.mp4", "pre_existing_b.mp4"}
 
 
     with (
     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()
         mock_list.assert_not_called()
 
 
     # Original baseline preserved.
     # Original baseline preserved.

+ 2 - 0
frontend/src/App.tsx

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

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

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

+ 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 { 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 React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { ToastProvider } from '../../contexts/ToastContext';
@@ -23,6 +23,14 @@ vi.mock('react-i18next', () => ({
         const { printer, slots } = options as { printer: string; slots: string };
         const { printer, slots } = options as { printer: string; slots: string };
         return `Missing assignments for ${printer}: ${slots}`;
         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;
       return key;
     },
     },
     i18n: {},
     i18n: {},
@@ -490,6 +498,52 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
       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 () => {
     it('handles spool_assignment_verified messages (success and failure) without error', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 

+ 3 - 2
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -368,7 +368,7 @@ describe('SettingsPage', () => {
 
 
       expect(localStorage.setItem).toHaveBeenCalledWith(
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
         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_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
       expect(localStorage.setItem).toHaveBeenCalledWith(
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
         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')
       const settingsRow = screen.getAllByText('Settings')
@@ -476,6 +476,7 @@ describe('SettingsPage', () => {
           'archives',
           'archives',
           'queue',
           'queue',
           'projects',
           'projects',
+          'finance',
           'files',
           'files',
           'makerworld',
           'makerworld',
           'profiles',
           'profiles',

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

@@ -1328,6 +1328,11 @@ export interface AppSettings {
   // Staggered batch start defaults
   // Staggered batch start defaults
   stagger_group_size: number;
   stagger_group_size: number;
   stagger_interval_minutes: 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
   // Plate-clear confirmation
   require_plate_clear: boolean;
   require_plate_clear: boolean;
   // Shortest job first scheduling
   // 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)
   // Either archive_id OR library_file_id must be set (archive created at print start)
   archive_id: number | null;
   archive_id: number | null;
   library_file_id: number | null;
   library_file_id: number | null;
+  cost_center_id: number | null;
+  estimated_cost: number | null;
   position: number;
   position: number;
   scheduled_time: string | null;
   scheduled_time: string | null;
   require_previous_success: boolean;
   require_previous_success: boolean;
@@ -2474,6 +2481,8 @@ export interface PrintQueueItemCreate {
   batch_id?: number | null;
   batch_id?: number | null;
   // Project to associate the resulting archive with
   // Project to associate the resulting archive with
   project_id?: number;
   project_id?: number;
+  cost_center_id?: number | null;
+  estimated_cost?: number | null;
   // Delete transient uploaded library file after scheduler creates the archive
   // Delete transient uploaded library file after scheduler creates the archive
   cleanup_library_after_dispatch?: boolean;
   cleanup_library_after_dispatch?: boolean;
   // Cross-model alternatives (#671): several sliced files, one job, whichever
   // Cross-model alternatives (#671): several sliced files, one job, whichever
@@ -2550,6 +2559,8 @@ export interface PrintQueueItemUpdate {
   preheat_chamber_target_override?: number | null;
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   // Auto-print G-code injection
   gcode_injection?: boolean;
   gcode_injection?: boolean;
+  cost_center_id?: number | null;
+  estimated_cost?: number | null;
 }
 }
 
 
 export interface PrintQueueBulkUpdate {
 export interface PrintQueueBulkUpdate {
@@ -2571,6 +2582,8 @@ export interface PrintQueueBulkUpdate {
   preheat_chamber_target_override?: number | null;
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   // Auto-print G-code injection
   gcode_injection?: boolean;
   gcode_injection?: boolean;
+  cost_center_id?: number | null;
+  estimated_cost?: number | null;
 }
 }
 
 
 export interface PrintQueueBulkUpdateResponse {
 export interface PrintQueueBulkUpdateResponse {
@@ -2688,6 +2701,7 @@ export interface NotificationProvider {
   on_print_stopped: boolean;
   on_print_stopped: boolean;
   on_print_progress: boolean;
   on_print_progress: boolean;
   on_print_missing_spool_assignment: boolean;
   on_print_missing_spool_assignment: boolean;
+  on_billing_charge_failed: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline: boolean;
   on_printer_offline: boolean;
   on_printer_error: boolean;
   on_printer_error: boolean;
@@ -2749,6 +2763,7 @@ export interface NotificationProviderCreate {
   on_print_stopped?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_progress?: boolean;
   on_print_missing_spool_assignment?: boolean;
   on_print_missing_spool_assignment?: boolean;
+  on_billing_charge_failed?: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
@@ -2803,6 +2818,7 @@ export interface NotificationProviderUpdate {
   on_print_stopped?: boolean;
   on_print_stopped?: boolean;
   on_print_progress?: boolean;
   on_print_progress?: boolean;
   on_print_missing_spool_assignment?: boolean;
   on_print_missing_spool_assignment?: boolean;
+  on_billing_charge_failed?: boolean;
   // Printer status events
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
@@ -3586,6 +3602,114 @@ export interface ExternalLinkUpdate {
   open_in_new_tab?: boolean;
   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
 // Permission type - all available permissions
 export type Permission =
 export type Permission =
   | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'
   | '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'
   | 'discovery:scan'
   | 'firmware:read' | 'firmware:update'
   | 'firmware:read' | 'firmware:update'
   | 'ams_history:read'
   | 'ams_history:read'
+  | 'cost_centers:read_own' | 'cost_centers:read_all' | 'cost_centers:modify' | 'cost_centers:create'
   | 'stats:read' | 'stats:filter_by_user'
   | 'stats:read' | 'stats:filter_by_user'
   | 'system:read'
   | 'system:read'
   | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
   | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
@@ -5497,6 +5622,79 @@ export const api = {
       body: JSON.stringify(profiles),
       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)
   // K-Profile Notes (stored locally, not on printer)
   getKProfileNotes: (printerId: number) =>
   getKProfileNotes: (printerId: number) =>
     request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),
     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 [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
   const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
   const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
+  const [onBillingChargeFailed, setOnBillingChargeFailed] = useState(provider?.on_billing_charge_failed ?? true);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
   const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
@@ -192,6 +193,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_print_failed: onPrintFailed,
       on_print_failed: onPrintFailed,
       on_print_stopped: onPrintStopped,
       on_print_stopped: onPrintStopped,
       on_print_progress: onPrintProgress,
       on_print_progress: onPrintProgress,
+      on_billing_charge_failed: onBillingChargeFailed,
       on_printer_offline: onPrinterOffline,
       on_printer_offline: onPrinterOffline,
       on_printer_error: onPrinterError,
       on_printer_error: onPrinterError,
       on_ai_failure_detection: onAiFailureDetection,
       on_ai_failure_detection: onAiFailureDetection,
@@ -605,6 +607,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.billingChargeFailedDescription')}</span>
+                  </div>
+                  <Toggle checked={onBillingChargeFailed} onChange={setOnBillingChargeFailed} />
+                </div>
                 <div className="flex items-center justify-between col-span-2">
                 <div className="flex items-center justify-between col-span-2">
                   <div>
                   <div>
                     <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
                     <span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
@@ -692,6 +701,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
+              if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
               if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
               if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });

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

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 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 { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -43,6 +43,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { 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: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
   { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
@@ -309,6 +310,7 @@ export function Layout() {
       maintenance: 'maintenance:read',
       maintenance: 'maintenance:read',
       projects: 'projects:read',
       projects: 'projects:read',
       inventory: 'inventory:read',
       inventory: 'inventory:read',
+      finance: 'cost_centers:read_own',
       files: ['library:read', 'library:read_own', 'library:read_all'],
       files: ['library:read', 'library:read_own', 'library:read_all'],
       makerworld: 'makerworld:view',
       makerworld: 'makerworld:view',
       settings: 'settings:read',
       settings: 'settings:read',

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

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

+ 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,
   filamentReqs,
   manualMappings,
   manualMappings,
   onManualMappingChange,
   onManualMappingChange,
+  onEstimatedCostChange,
+  budgetAvailable,
+  quantity = 1,
   currencySymbol,
   currencySymbol,
   defaultCostPerKg,
   defaultCostPerKg,
   defaultExpanded = false,
   defaultExpanded = false,
@@ -170,10 +173,24 @@ export function FilamentMapping({
     return total;
     return total;
   }, [filamentComparison, trayCostMap, defaultCostPerKg]);
   }, [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(
   const hasAnyCost = useMemo(
     () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),
     () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),
     [trayCostMap]
     [trayCostMap]
   );
   );
+  const budgetCheckCost = totalCost * Math.max(1, quantity);
+  const isBudgetInsufficient = budgetAvailable != null && budgetCheckCost > budgetAvailable;
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
   const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
   const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
 
 
@@ -416,7 +433,19 @@ export function FilamentMapping({
             <span className="text-white">
             <span className="text-white">
               {totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
               {totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}
             </span>
             </span>
+            {quantity > 1 && totalCost > 0 && (
+              <span className="ml-2">
+                {t('printModal.totalCostForQuantity', 'total: {{cost}}', {
+                  cost: `${currencySymbol}${budgetCheckCost.toFixed(2)}`,
+                })}
+              </span>
+            )}
           </div>
           </div>
+          {isBudgetInsufficient && (
+            <p className="text-xs text-red-400 mt-2">
+              {t('printModal.insufficientBudget', 'Insufficient budget for this cost center.')}
+            </p>
+          )}
           {hasTypeMismatch && (
           {hasTypeMismatch && (
             <p className="text-xs text-orange-700 dark:text-orange-400 mt-2">Required filament type not found in printer.</p>
             <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 { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 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 { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
 import { useAuth } from '../../contexts/AuthContext';
 import { Card, CardContent } from '../Card';
 import { Card, CardContent } from '../Card';
@@ -30,6 +30,7 @@ import { PrinterSelector } from './PrinterSelector';
 import { PrintOptionsPanel } from './PrintOptions';
 import { PrintOptionsPanel } from './PrintOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
 import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
 import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
+import { CostCenterSelect } from './CostCenterSelect';
 import type {
 import type {
   AssignmentMode,
   AssignmentMode,
   FilamentReqsData,
   FilamentReqsData,
@@ -64,7 +65,7 @@ export function PrintModal({
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, user } = useAuth();
 
 
   // Determine if we're printing a library file
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
   const isLibraryFile = !!libraryFileId && !archiveId;
@@ -221,6 +222,12 @@ export function PrintModal({
     return null;
     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}
   // Filament overrides for model-based assignment: slot_id -> {type, color}
   const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
   const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
     if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
     if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
@@ -301,12 +308,37 @@ export function PrintModal({
 
 
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
+  const billingEnabled = settings?.billing_enabled === true;
 
 
   const { data: printers, isLoading: loadingPrinters } = useQuery({
   const { data: printers, isLoading: loadingPrinters } = useQuery({
     queryKey: ['printers'],
     queryKey: ['printers'],
     queryFn: api.getPrinters,
     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({
   const { data: spoolAssignments } = useQuery({
     queryKey: ['spool-assignments'],
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
     queryFn: () => api.getAssignments(),
@@ -792,6 +824,11 @@ export function PrintModal({
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
     e?.preventDefault();
 
 
+    if (billingEnabled && selectedCostCenter == null) {
+      showToast(t('printModal.noPrintableCostCenters'), 'error');
+      return;
+    }
+
     if (
     if (
       !options?.skipFilamentCheck &&
       !options?.skipFilamentCheck &&
       !settings?.disable_filament_warnings &&
       !settings?.disable_filament_warnings &&
@@ -1050,6 +1087,8 @@ export function PrintModal({
     // Common queue data for create and edit modes
     // Common queue data for create and edit modes
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
       const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
       const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
+      const plateEstimatedCost =
+        plateId != null && isMultiPlateSelection ? estimatedCostsByPlate[plateId] ?? null : estimatedCost;
       return {
       return {
       printer_id: assignmentMode === 'printer' ? printerId : null,
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
@@ -1073,6 +1112,8 @@ export function PrintModal({
         : undefined,
         : undefined,
       ...printOptions,
       ...printOptions,
       project_id: projectId ?? undefined,
       project_id: projectId ?? undefined,
+      cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+      estimated_cost: billingEnabled && selectedCostCenterId != null ? plateEstimatedCost : undefined,
       batch_id: autoBatchId ?? undefined,
       batch_id: autoBatchId ?? undefined,
       cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
       cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
       };
       };
@@ -1104,6 +1145,8 @@ export function PrintModal({
                 ? new Date(scheduleOptions.scheduledTime).toISOString()
                 ? new Date(scheduleOptions.scheduledTime).toISOString()
                 : null,
                 : null,
               ...printOptions,
               ...printOptions,
+              cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+              estimated_cost: billingEnabled && selectedCostCenterId != null ? estimatedCost : undefined,
             };
             };
             await updateQueueMutation.mutateAsync(updateData);
             await updateQueueMutation.mutateAsync(updateData);
           } else {
           } else {
@@ -1160,6 +1203,12 @@ export function PrintModal({
                   ? new Date(scheduleOptions.scheduledTime).toISOString()
                   ? new Date(scheduleOptions.scheduledTime).toISOString()
                   : null,
                   : null,
                 ...printOptions,
                 ...printOptions,
+                cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
+                estimated_cost: billingEnabled && selectedCostCenterId != null
+                  ? (plateId != null && isMultiPlateSelection
+                    ? estimatedCostsByPlate[plateId] ?? null
+                    : estimatedCost)
+                  : undefined,
               };
               };
               await updateQueueMutation.mutateAsync(updateData);
               await updateQueueMutation.mutateAsync(updateData);
             } else {
             } else {
@@ -1233,6 +1282,11 @@ export function PrintModal({
   const canSubmit = useMemo(() => {
   const canSubmit = useMemo(() => {
     if (isPending) return false;
     if (isPending) return false;
 
 
+    // Billing requires a server-authorized cost center. Wait for the query and
+    // keep submission disabled when the user has no printable center, rather
+    // than letting the API fail with an unexplained 400.
+    if (billingEnabled && (loadingCostCenters || selectedCostCenter == null)) return false;
+
     // Need valid printer/model selection
     // Need valid printer/model selection
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
     // Both are about the single-model case. A cross-model job has no one target
     // Both are about the single-model case. A cross-model job has no one target
@@ -1269,6 +1323,9 @@ export function PrintModal({
     perPlateReqsFailed,
     perPlateReqsFailed,
     printerStatusLoading,
     printerStatusLoading,
     isCrossModel,
     isCrossModel,
+    billingEnabled,
+    loadingCostCenters,
+    selectedCostCenter,
   ]);
   ]);
 
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
@@ -1340,6 +1397,12 @@ export function PrintModal({
     isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
     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
   // 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
   // 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
   // plate *per printer*, so those items ship without a mapping and the scheduler
@@ -1600,6 +1663,9 @@ export function PrintModal({
                 filamentReqs={effectiveFilamentReqs}
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
                 onManualMappingChange={setManualMappings}
+                onEstimatedCostChange={setEstimatedCost}
+                budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
+                quantity={effectiveQuantity}
                 defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
                 defaultCostPerKg={defaultCostPerKg}
@@ -1627,6 +1693,11 @@ export function PrintModal({
                   onManualMappingChange={(mappings) =>
                   onManualMappingChange={(mappings) =>
                     setManualMappingsByPlate((prev) => ({ ...prev, [plateId]: 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}
                   defaultExpanded={false}
                   currencySymbol={currencySymbol}
                   currencySymbol={currencySymbol}
                   defaultCostPerKg={defaultCostPerKg}
                   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
             {/* Quantity — create multiple copies (batch). Hidden for multi-printer
                 selection, and for multi-plate files where the per-plate steppers
                 selection, and for multi-plate files where the per-plate steppers
                 in PlateSelector own the number instead (#342). */}
                 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;
   filamentReqs: FilamentReqsData | undefined;
   manualMappings: Record<number, number>;
   manualMappings: Record<number, number>;
   onManualMappingChange: (mappings: Record<number, number>) => void;
   onManualMappingChange: (mappings: Record<number, number>) => void;
+  onEstimatedCostChange?: (estimatedCost: number | null) => void;
+  budgetAvailable?: number | null;
+  quantity?: number;
   currencySymbol: string;
   currencySymbol: string;
   defaultCostPerKg: number;
   defaultCostPerKg: number;
   /** Per-slot force-color-match flags. The scheduler honors this flag in both
   /** 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;
   printer_id?: number;
   data?: Record<string, unknown>;
   data?: Record<string, unknown>;
   printer_name?: string;
   printer_name?: string;
+  filename?: string;
   missing_slots?: Array<{ slot?: string }>;
   missing_slots?: Array<{ slot?: string }>;
   // Spool-assignment read-back verification (#2582).
   // Spool-assignment read-back verification (#2582).
   slot?: string;
   slot?: string;
@@ -338,6 +339,20 @@ export function useWebSocket() {
         debouncedInvalidate('archiveStats');
         debouncedInvalidate('archiveStats');
         break;
         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':
       case 'archive_created':
         debouncedInvalidate('archives');
         debouncedInvalidate('archives');
         debouncedInvalidate('archiveStats');
         debouncedInvalidate('archiveStats');

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

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profile',
     profiles: 'Profile',
     maintenance: 'Wartung',
     maintenance: 'Wartung',
     projects: 'Projekte',
     projects: 'Projekte',
+    finance: 'Finanzen',
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'Dateimanager',
     files: 'Dateimanager',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: 'Aktionen',
     actions: 'Aktionen',
     status: 'Status',
     status: 'Status',
     name: 'Name',
     name: 'Name',
+    user: 'Benutzer',
     description: 'Beschreibung',
     description: 'Beschreibung',
     date: 'Datum',
     date: 'Datum',
     time: 'Zeit',
     time: 'Zeit',
@@ -124,6 +126,97 @@ export default {
     duplicate: 'Duplizieren',
     duplicate: 'Duplizieren',
     left: 'Links',
     left: 'Links',
     right: 'Rechts',
     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
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Drucker gelöscht',
       printerDeleted: 'Drucker gelöscht',
       missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
       missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
+      killSwitchTriggered: 'Der Billing-Kill-Switch hat einen nicht autorisierten Druck auf {{printer}} gestoppt: {{filename}}',
+      billingChargeFailed: 'Die Abrechnung für {{filename}} auf {{printer}} ist fehlgeschlagen. Die Budgetreservierung bleibt bestehen; prüfe die Serverprotokolle.',
       assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
       assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
       assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
       assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
@@ -2294,6 +2389,21 @@ export default {
     },
     },
     externalCameras: 'Externe Kameras',
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
     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',
     printsOnly: 'Nur Drucke',
     totalConsumption: 'Gesamtverbrauch',
     totalConsumption: 'Gesamtverbrauch',
     dataManagement: 'Datenverwaltung',
     dataManagement: 'Datenverwaltung',
@@ -3992,7 +4102,7 @@ export default {
       title: 'Kostenverfolgung',
       title: 'Kostenverfolgung',
       filamentCost: 'Filamentkosten',
       filamentCost: 'Filamentkosten',
       energy: 'Energie',
       energy: 'Energie',
-      totalCost: 'Gesamtkosten',
+      totalCost: 'Erwartete Gesamtkosten',
       total: 'Gesamt',
       total: 'Gesamt',
       includesBom: 'inkl. Stückliste',
       includesBom: 'inkl. Stückliste',
       budget: 'Budget',
       budget: 'Budget',
@@ -4799,6 +4909,9 @@ export default {
     staggerTotal: 'insgesamt: {{minutes}} Min.',
     staggerTotal: 'insgesamt: {{minutes}} Min.',
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     gcodeInjection: 'Auto-Print G-code einfügen',
     gcodeInjection: 'Auto-Print G-code einfügen',
+    insufficientBudget: 'Budget nicht ausreichend',
+    unlimitedNoBudget: 'Unbegrenzt – es ist kein Budgetlimit festgelegt.',
+    noPrintableCostCenters: 'Es ist keine aktive Kostenstelle zum Drucken verfügbar. Bitte einen Administrator, dir Druckzugriff zu gewähren.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5791,6 +5904,8 @@ export default {
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
     missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
+    billingChargeFailedLabel: 'Abrechnungsfehler',
+    billingChargeFailedDescription: 'Benachrichtigen, wenn Druckkosten nicht verbucht werden konnten',
     missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
     missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
     printFailed: 'Druck fehlgeschlagen',
     printFailed: 'Druck fehlgeschlagen',
     printStopped: 'Druck gestoppt',
     printStopped: 'Druck gestoppt',

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

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profiles',
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
     projects: 'Projects',
     projects: 'Projects',
+    finance: 'Finance',
     inventory: 'Filament',
     inventory: 'Filament',
     files: 'File Manager',
     files: 'File Manager',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: 'Actions',
     actions: 'Actions',
     status: 'Status',
     status: 'Status',
     name: 'Name',
     name: 'Name',
+    user: 'User',
     description: 'Description',
     description: 'Description',
     date: 'Date',
     date: 'Date',
     time: 'Time',
     time: 'Time',
@@ -124,6 +126,97 @@ export default {
     duplicate: 'Duplicate',
     duplicate: 'Duplicate',
     left: 'Left',
     left: 'Left',
     right: 'Right',
     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
   // Printers page
@@ -359,6 +452,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Printer deleted',
       printerDeleted: 'Printer deleted',
       missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
       missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
+      killSwitchTriggered: 'The billing kill switch stopped an unauthorized print on {{printer}}: {{filename}}',
+      billingChargeFailed: 'Billing failed for {{filename}} on {{printer}}. The budget reservation was retained; check the server logs.',
       assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
       assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
       assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
       assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
@@ -2313,6 +2408,21 @@ export default {
     },
     },
     externalCameras: 'External Cameras',
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
     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',
     printsOnly: 'Prints Only',
     totalConsumption: 'Total Consumption',
     totalConsumption: 'Total Consumption',
     dataManagement: 'Data Management',
     dataManagement: 'Data Management',
@@ -4021,7 +4131,7 @@ export default {
       title: 'Cost Tracking',
       title: 'Cost Tracking',
       filamentCost: 'Filament Cost',
       filamentCost: 'Filament Cost',
       energy: 'Energy',
       energy: 'Energy',
-      totalCost: 'Total Cost',
+      totalCost: 'Estimated Cost',
       total: 'Total',
       total: 'Total',
       includesBom: 'incl. BOM',
       includesBom: 'incl. BOM',
       budget: 'Budget',
       budget: 'Budget',
@@ -4842,6 +4952,9 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Stagger to {{count}} printers',
     staggerToPrinters: 'Stagger to {{count}} printers',
     gcodeInjection: 'Inject auto-print G-code',
     gcodeInjection: 'Inject auto-print G-code',
+    insufficientBudget: 'Insufficient Budget',
+    unlimitedNoBudget: 'Unlimited – no budget limit is set.',
+    noPrintableCostCenters: 'No active cost center is available for printing. Ask an administrator to grant you print access.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5840,6 +5953,8 @@ export default {
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     missingSpoolAssignmentLabel: 'Missing Spool Assignment',
     missingSpoolAssignmentLabel: 'Missing Spool Assignment',
+    billingChargeFailedLabel: 'Billing Charge Failed',
+    billingChargeFailedDescription: 'Notify when print costs could not be recorded',
     missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
     missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
     printFailed: 'Print Failed',
     printFailed: 'Print Failed',
     printStopped: 'Print Stopped',
     printStopped: 'Print Stopped',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Mantenimiento',
     maintenance: 'Mantenimiento',
     projects: 'Proyectos',
     projects: 'Proyectos',
     inventory: 'Filamento',
     inventory: 'Filamento',
+    finance: 'Finanzas',
     files: 'Gestor de archivos',
     files: 'Gestor de archivos',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: 'Notificaciones',
     notifications: 'Notificaciones',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Duplicar',
     duplicate: 'Duplicar',
     left: 'Izquierda',
     left: 'Izquierda',
     right: 'Derecha',
     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
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Impresora eliminada',
       printerDeleted: 'Impresora eliminada',
       missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
       missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
+      killSwitchTriggered: 'El interruptor de seguridad de facturación detuvo una impresión no autorizada en {{printer}}: {{filename}}',
+      billingChargeFailed: 'La facturación de {{filename}} en {{printer}} ha fallado. Se conservó la reserva de presupuesto; revisa los registros del servidor.',
       assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
       assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
       assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
       assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
       assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
@@ -2297,6 +2392,20 @@ export default {
     },
     },
     externalCameras: 'Cámaras externas',
     externalCameras: 'Cámaras externas',
     costTracking: 'Seguimiento de costes',
     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',
     printsOnly: 'Solo impresiones',
     totalConsumption: 'Consumo total',
     totalConsumption: 'Consumo total',
     dataManagement: 'Gestión de datos',
     dataManagement: 'Gestión de datos',
@@ -4807,6 +4916,9 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Escalonar en {{count}} impresoras',
     staggerToPrinters: 'Escalonar en {{count}} impresoras',
     gcodeInjection: 'Inyectar G-code de impresión automática',
     gcodeInjection: 'Inyectar G-code de impresión automática',
+    insufficientBudget: 'Presupuesto insuficiente',
+    unlimitedNoBudget: 'Ilimitado: no se ha establecido ningún límite de presupuesto.',
+    noPrintableCostCenters: 'No hay ningún centro de costes activo disponible para imprimir. Pide a un administrador que te conceda acceso de impresión.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5800,6 +5912,8 @@ export default {
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
     missingSpoolAssignmentLabel: 'Falta la asignación de bobina',
     missingSpoolAssignmentLabel: 'Falta la asignación de bobina',
+    billingChargeFailedLabel: 'Error de facturación',
+    billingChargeFailedDescription: 'Notificar cuando no se puedan registrar los costes de impresión',
     missingSpoolAssignmentDescription: 'Notificar cuando la impresión comienza y las bandejas necesarias no tienen ninguna bobina asignada',
     missingSpoolAssignmentDescription: 'Notificar cuando la impresión comienza y las bandejas necesarias no tienen ninguna bobina asignada',
     printFailed: 'Impresión fallida',
     printFailed: 'Impresión fallida',
     printStopped: 'Impresión detenida',
     printStopped: 'Impresión detenida',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
     projects: 'Projets',
     projects: 'Projets',
     inventory: 'Filament',
     inventory: 'Filament',
+    finance: 'Finances',
     files: 'Gestionnaire de fichiers',
     files: 'Gestionnaire de fichiers',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: 'Notifications',
     notifications: 'Notifications',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Dupliquer',
     duplicate: 'Dupliquer',
     left: 'Gauche',
     left: 'Gauche',
     right: 'Droite',
     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
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Imprimante supprimée',
       printerDeleted: 'Imprimante supprimée',
       missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
       missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
+      killSwitchTriggered: 'Le coupe-circuit de facturation a arrêté une impression non autorisée sur {{printer}} : {{filename}}',
+      billingChargeFailed: 'La facturation de {{filename}} sur {{printer}} a échoué. La réservation budgétaire a été conservée ; consultez les journaux du serveur.',
       assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
       assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
       assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
       assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
       assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
@@ -2268,6 +2363,21 @@ export default {
     useTls: 'Utiliser TLS',
     useTls: 'Utiliser TLS',
     enableMetricsEndpoint: 'Activer l\'endpoint Metrics',
     enableMetricsEndpoint: 'Activer l\'endpoint Metrics',
     availableMetrics: 'Metrics disponibles',
     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',
     editUser: 'Modifier l\'utilisateur',
     deleteUserTitle: 'Supprimer l\'utilisateur',
     deleteUserTitle: 'Supprimer l\'utilisateur',
     groupName: 'Nom du groupe',
     groupName: 'Nom du groupe',
@@ -4788,6 +4898,9 @@ export default {
     staggerTotal: 'total : {{minutes}} min',
     staggerTotal: 'total : {{minutes}} min',
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
     gcodeInjection: 'Injecter le G-code auto-impression',
     gcodeInjection: 'Injecter le G-code auto-impression',
+    insufficientBudget: 'Budget insuffisant',
+    unlimitedNoBudget: 'Illimité – aucune limite de budget n’est définie.',
+    noPrintableCostCenters: 'Aucun centre de coûts actif n’est disponible pour l’impression. Demandez à un administrateur de vous accorder l’accès à l’impression.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5781,6 +5894,8 @@ export default {
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
     missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
+    billingChargeFailedLabel: 'Échec de facturation',
+    billingChargeFailedDescription: "Notifier lorsque les coûts d'impression ne peuvent pas être enregistrés",
     missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
     missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
     printFailed: 'Impression échouée',
     printFailed: 'Impression échouée',
     printStopped: 'Impression arrêtée',
     printStopped: 'Impression arrêtée',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Manutenzione',
     maintenance: 'Manutenzione',
     projects: 'Progetti',
     projects: 'Progetti',
     inventory: 'Filamento',
     inventory: 'Filamento',
+    finance: 'Finanze',
     files: 'File',
     files: 'File',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: 'Notifiche',
     notifications: 'Notifiche',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Duplica',
     duplicate: 'Duplica',
     left: 'Sinistra',
     left: 'Sinistra',
     right: 'Destra',
     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
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Stampante eliminata',
       printerDeleted: 'Stampante eliminata',
       missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
       missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
+      killSwitchTriggered: 'L’interruttore di sicurezza della fatturazione ha arrestato una stampa non autorizzata su {{printer}}: {{filename}}',
+      billingChargeFailed: 'La fatturazione di {{filename}} su {{printer}} non è riuscita. La prenotazione del budget è stata mantenuta; controlla i log del server.',
       assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
       assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
       assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
       assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
       assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
@@ -2268,6 +2363,21 @@ export default {
     useTls: 'Usa TLS',
     useTls: 'Usa TLS',
     enableMetricsEndpoint: 'Abilita endpoint metriche',
     enableMetricsEndpoint: 'Abilita endpoint metriche',
     availableMetrics: 'Metriche disponibili',
     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',
     editUser: 'Modifica utente',
     deleteUserTitle: 'Elimina utente',
     deleteUserTitle: 'Elimina utente',
     groupName: 'Nome gruppo',
     groupName: 'Nome gruppo',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: 'totale: {{minutes}} min',
     staggerTotal: 'totale: {{minutes}} min',
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     gcodeInjection: 'Inietta G-code auto-stampa',
     gcodeInjection: 'Inietta G-code auto-stampa',
+    insufficientBudget: 'Budget insufficiente',
+    unlimitedNoBudget: 'Illimitato – non è impostato alcun limite di budget.',
+    noPrintableCostCenters: 'Non è disponibile alcun centro di costo attivo per la stampa. Chiedi a un amministratore di concederti l’accesso alla stampa.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5780,6 +5893,8 @@ export default {
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
     missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
+    billingChargeFailedLabel: 'Errore di addebito',
+    billingChargeFailedDescription: 'Notifica quando non è possibile registrare i costi di stampa',
     missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
     missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
     printFailed: 'Stampa fallita',
     printFailed: 'Stampa fallita',
     printStopped: 'Stampa interrotta',
     printStopped: 'Stampa interrotta',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'メンテナンス',
     maintenance: 'メンテナンス',
     projects: 'プロジェクト',
     projects: 'プロジェクト',
     inventory: 'フィラメント',
     inventory: 'フィラメント',
+    finance: 'ファイナンス',
     files: 'ファイル管理',
     files: 'ファイル管理',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: '通知',
     notifications: '通知',
@@ -124,6 +125,98 @@ export default {
     duplicate: '複製',
     duplicate: '複製',
     left: '左',
     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: 'ページ {{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 page
   printers: {
   printers: {
@@ -355,6 +448,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'プリンターを削除しました',
       printerDeleted: 'プリンターを削除しました',
       missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
       missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
+      killSwitchTriggered: '課金キルスイッチが{{printer}}で未承認の印刷を停止しました:{{filename}}',
+      billingChargeFailed: '{{printer}} の {{filename}} を課金できませんでした。予算予約は保持されています。サーバーログを確認してください。',
       assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
       assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
       assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
       assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
       assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
@@ -2311,6 +2406,21 @@ export default {
     useTls: 'TLSを使用',
     useTls: 'TLSを使用',
     enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
     enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
     availableMetrics: '利用可能なメトリクス',
     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: 'ユーザーを編集',
     editUser: 'ユーザーを編集',
     deleteUserTitle: 'ユーザーを削除',
     deleteUserTitle: 'ユーザーを削除',
     groupName: 'グループ名',
     groupName: 'グループ名',
@@ -4799,6 +4909,9 @@ export default {
     staggerTotal: '合計: {{minutes}}分',
     staggerTotal: '合計: {{minutes}}分',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     gcodeInjection: '自動印刷G-codeを挿入',
     gcodeInjection: '自動印刷G-codeを挿入',
+    insufficientBudget: '予算が不足しています',
+    unlimitedNoBudget: '無制限 – 予算上限は設定されていません。',
+    noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。',
   },
   },
 
 
   // Backup
   // Backup
@@ -5792,6 +5905,8 @@ export default {
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     missingSpoolAssignmentLabel: 'スプール割り当て不足',
     missingSpoolAssignmentLabel: 'スプール割り当て不足',
+    billingChargeFailedLabel: '請求処理エラー',
+    billingChargeFailedDescription: '印刷コストを記録できなかった場合に通知します',
     missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
     printFailed: '印刷失敗',
     printFailed: '印刷失敗',
     printStopped: '印刷停止',
     printStopped: '印刷停止',

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

@@ -8,6 +8,7 @@ export default {
     maintenance: '유지보수',
     maintenance: '유지보수',
     projects: '프로젝트',
     projects: '프로젝트',
     inventory: '필라멘트',
     inventory: '필라멘트',
+    finance: '재무',
     files: '파일 관리자',
     files: '파일 관리자',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: '알림',
     notifications: '알림',
@@ -120,7 +121,98 @@ export default {
     create: '만들기',
     create: '만들기',
     duplicate: '복제',
     duplicate: '복제',
     left: '왼쪽',
     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: {
   printers: {
     title: '프린터',
     title: '프린터',
@@ -331,6 +423,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: '프린터가 삭제되었습니다',
       printerDeleted: '프린터가 삭제되었습니다',
       missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
       missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
+      killSwitchTriggered: '결제 킬 스위치가 {{printer}}에서 승인되지 않은 인쇄를 중지했습니다: {{filename}}',
+      billingChargeFailed: '{{printer}}의 {{filename}} 결제에 실패했습니다. 예산 예약은 유지되었습니다. 서버 로그를 확인하세요.',
       assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
       assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
       assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
       assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
       assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
@@ -2167,6 +2261,20 @@ export default {
     },
     },
     externalCameras: '외부 카메라',
     externalCameras: '외부 카메라',
     costTracking: '비용 추적',
     costTracking: '비용 추적',
+    billingEnabled: '결제 기능 사용',
+    billingEnabledDescription: '사용자에게 인쇄 비용을 청구하고 재무 기능을 사용합니다',
+    printerKillSwitch: '무단 인쇄 자동 중지',
+    printerKillSwitchDescription: '승인 없이 시작된 인쇄를 즉시 중지합니다.',
+    financeBudgetReset: '월별 재무 예산 초기화',
+    financeBudgetResetDay: '초기화 날짜',
+    financeBudgetResetDayHelp: '해당 날짜가 없는 짧은 달에는 그 달의 마지막 날에 초기화합니다.',
+    financeBudgetResetTimezone: '초기화 시간대',
+    financeBudgetResetTimezoneHelp: '예산 기간의 시작은 이 시간대를 기준으로 계산됩니다.',
+    rebuildLedger: '지갑 원장 재구성',
+    rebuildLedgerStarted: '원장 재구성을 시작했습니다',
+    rebuildLedgerConfirmTitle: '지갑 원장을 재구성하시겠습니까?',
+    rebuildLedgerConfirmMessage: '과거 잔액 값을 복구하기 위해 지갑 원장을 재구성합니다. 수행 내용을 정확히 아는 경우에만 실행하세요.',
+    rebuildLedgerInProgress: '재구성 시작 중...',
     printsOnly: '인쇄만',
     printsOnly: '인쇄만',
     totalConsumption: '총 소비',
     totalConsumption: '총 소비',
     dataManagement: '데이터 관리',
     dataManagement: '데이터 관리',
@@ -4570,7 +4678,10 @@ export default {
     staggerLastGroup: '마지막 그룹: {{count}}',
     staggerLastGroup: '마지막 그룹: {{count}}',
     staggerTotal: '합계: {{minutes}}분',
     staggerTotal: '합계: {{minutes}}분',
     staggerToPrinters: '{{count}}대 프린터에 분산',
     staggerToPrinters: '{{count}}대 프린터에 분산',
-    gcodeInjection: '자동 인쇄 G-code 삽입'
+    gcodeInjection: '자동 인쇄 G-code 삽입',
+    insufficientBudget: '예산 부족',
+    unlimitedNoBudget: '무제한 – 예산 한도가 설정되지 않았습니다.',
+    noPrintableCostCenters: '인쇄에 사용할 수 있는 활성 비용 센터가 없습니다. 관리자에게 인쇄 권한을 요청하세요.',
   },
   },
   backup: {
   backup: {
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
@@ -5512,6 +5623,8 @@ export default {
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
     missingSpoolAssignmentLabel: '스풀 할당 누락',
     missingSpoolAssignmentLabel: '스풀 할당 누락',
+    billingChargeFailedLabel: '결제 처리 실패',
+    billingChargeFailedDescription: '인쇄 비용을 기록하지 못한 경우 알림',
     missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림',
     missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림',
     printFailed: '인쇄 실패',
     printFailed: '인쇄 실패',
     printStopped: '인쇄 중지됨',
     printStopped: '인쇄 중지됨',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Manutenção',
     maintenance: 'Manutenção',
     projects: 'Projetos',
     projects: 'Projetos',
     inventory: 'Inventário',
     inventory: 'Inventário',
+    finance: 'Finanças',
     files: 'Gerenciador de Arquivos',
     files: 'Gerenciador de Arquivos',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: 'Notificações',
     notifications: 'Notificações',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Duplicar',
     duplicate: 'Duplicar',
     left: 'Esquerda',
     left: 'Esquerda',
     right: 'Direita',
     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
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Impressora excluída',
       printerDeleted: 'Impressora excluída',
       missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
       missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
+      killSwitchTriggered: 'O bloqueio de segurança de cobrança interrompeu uma impressão não autorizada em {{printer}}: {{filename}}',
+      billingChargeFailed: 'A cobrança de {{filename}} em {{printer}} falhou. A reserva do orçamento foi mantida; verifique os logs do servidor.',
       assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
       assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
       assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
       assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
       assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
       assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
@@ -2268,6 +2363,21 @@ export default {
     useTls: 'Usar TLS',
     useTls: 'Usar TLS',
     enableMetricsEndpoint: 'Ativar Endpoint de Métricas',
     enableMetricsEndpoint: 'Ativar Endpoint de Métricas',
     availableMetrics: 'Métricas Disponíveis',
     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',
     editUser: 'Editar Usuário',
     deleteUserTitle: 'Excluir Usuário',
     deleteUserTitle: 'Excluir Usuário',
     groupName: 'Nome do Grupo',
     groupName: 'Nome do Grupo',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Escalonar para {{count}} impressoras',
     staggerToPrinters: 'Escalonar para {{count}} impressoras',
     gcodeInjection: 'Injetar G-code de auto-impressão',
     gcodeInjection: 'Injetar G-code de auto-impressão',
+    insufficientBudget: 'Orçamento insuficiente',
+    unlimitedNoBudget: 'Ilimitado – nenhum limite de orçamento foi definido.',
+    noPrintableCostCenters: 'Não há nenhum centro de custo ativo disponível para impressão. Peça a um administrador para conceder acesso de impressão.',
   },
   },
 
 
   // Backup
   // Backup
@@ -5780,6 +5893,8 @@ export default {
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
     missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
     missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
+    billingChargeFailedLabel: 'Falha na cobrança',
+    billingChargeFailedDescription: 'Notificar quando os custos de impressão não puderem ser registrados',
     missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
     missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
     printFailed: 'Impressão Falhou',
     printFailed: 'Impressão Falhou',
     printStopped: 'Impressão Parada',
     printStopped: 'Impressão Parada',

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

@@ -8,6 +8,7 @@ export default {
     maintenance: "Обслуживание",
     maintenance: "Обслуживание",
     projects: "Проекты",
     projects: "Проекты",
     inventory: "Филамент",
     inventory: "Филамент",
+    finance: "Финансы",
     files: "Файловый менеджер",
     files: "Файловый менеджер",
     makerworld: "MakerWorld",
     makerworld: "MakerWorld",
     notifications: "Уведомления",
     notifications: "Уведомления",
@@ -121,6 +122,97 @@ export default {
     duplicate: "Дублировать",
     duplicate: "Дублировать",
     left: "Левый",
     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: "Страница {{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: {
   printers: {
     title: "Принтеры",
     title: "Принтеры",
@@ -336,6 +428,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: "Принтер удалён",
       printerDeleted: "Принтер удалён",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
       missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
+      killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не удалось начислить стоимость {{filename}} на {{printer}}. Резерв бюджета сохранён; проверьте журналы сервера.',
       assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
       assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
       assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
       assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
       assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
       assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
@@ -2168,6 +2262,20 @@ export default {
     },
     },
     externalCameras: "Внешние камеры",
     externalCameras: "Внешние камеры",
     costTracking: "Учёт затрат",
     costTracking: "Учёт затрат",
+    billingEnabled: "Включить расчёты",
+    billingEnabledDescription: "Списывать с пользователей стоимость печати и включить финансовые функции",
+    printerKillSwitch: "Автоостановка несанкционированной печати",
+    printerKillSwitchDescription: "Немедленно останавливает печать, начатую без разрешения.",
+    financeBudgetReset: "Ежемесячный сброс финансового бюджета",
+    financeBudgetResetDay: "День сброса",
+    financeBudgetResetDayHelp: "В коротких месяцах сброс выполняется в последний день месяца.",
+    financeBudgetResetTimezone: "Часовой пояс сброса",
+    financeBudgetResetTimezoneHelp: "Начало бюджетного периода рассчитывается в этом часовом поясе.",
+    rebuildLedger: "Пересчитать книгу кошелька",
+    rebuildLedgerStarted: "Пересчёт книги запущен",
+    rebuildLedgerConfirmTitle: "Пересчитать книгу кошелька?",
+    rebuildLedgerConfirmMessage: "Книга кошелька будет пересчитана для исправления исторических значений баланса. Запускайте это действие только если понимаете его последствия.",
+    rebuildLedgerInProgress: "Запуск пересчёта...",
     printsOnly: "Только печать",
     printsOnly: "Только печать",
     totalConsumption: "Общее потребление",
     totalConsumption: "Общее потребление",
     dataManagement: "Управление данными",
     dataManagement: "Управление данными",
@@ -4560,6 +4668,9 @@ export default {
     staggerTotal: "всего: {{minutes}} мин",
     staggerTotal: "всего: {{minutes}} мин",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     gcodeInjection: "Добавить G-code автозапуска",
     gcodeInjection: "Добавить G-code автозапуска",
+    insufficientBudget: "Недостаточно бюджета",
+    unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
+    noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
   },
   },
   backup: {
   backup: {
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5499,6 +5610,8 @@ export default {
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
     missingSpoolAssignmentLabel: "Катушка не назначена",
     missingSpoolAssignmentLabel: "Катушка не назначена",
+    billingChargeFailedLabel: 'Ошибка списания',
+    billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати',
     missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
     missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
     printFailed: "Ошибка печати",
     printFailed: "Ошибка печати",
     printStopped: "Печать остановлена",
     printStopped: "Печать остановлена",

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Bakım',
     maintenance: 'Bakım',
     projects: 'Projeler',
     projects: 'Projeler',
     inventory: 'Filament',
     inventory: 'Filament',
+    finance: 'Finans',
     files: 'Dosya Yöneticisi',
     files: 'Dosya Yöneticisi',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
     notifications: 'Bildirimler',
     notifications: 'Bildirimler',
@@ -124,6 +125,98 @@ export default {
     duplicate: 'Çoğalt',
     duplicate: 'Çoğalt',
     left: 'Sol',
     left: 'Sol',
     right: 'Sağ',
     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ı
   // Yazıcılar sayfası
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: 'Yazıcı silindi',
       printerDeleted: 'Yazıcı silindi',
       missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
       missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
+      killSwitchTriggered: 'Faturalandırma durdurma anahtarı {{printer}} üzerindeki yetkisiz baskıyı durdurdu: {{filename}}',
+      billingChargeFailed: '{{printer}} üzerindeki {{filename}} için faturalandırma başarısız oldu. Bütçe rezervasyonu korundu; sunucu günlüklerini kontrol edin.',
       assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
       assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
       assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
       assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
       assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
@@ -2298,6 +2393,20 @@ export default {
     },
     },
     externalCameras: 'Harici Kameralar',
     externalCameras: 'Harici Kameralar',
     costTracking: 'Maliyet Takibi',
     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',
     printsOnly: 'Yalnızca Baskılar',
     totalConsumption: 'Toplam Tüketim',
     totalConsumption: 'Toplam Tüketim',
     dataManagement: 'Veri Yönetimi',
     dataManagement: 'Veri Yönetimi',
@@ -4777,6 +4886,9 @@ export default {
     staggerTotal: 'toplam: {{minutes}} dk',
     staggerTotal: 'toplam: {{minutes}} dk',
     staggerToPrinters: '{{count}} yazıcıya kademelendir',
     staggerToPrinters: '{{count}} yazıcıya kademelendir',
     gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
     gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
+    insufficientBudget: 'Yetersiz bütçe',
+    unlimitedNoBudget: 'Sınırsız – bütçe limiti belirlenmemiş.',
+    noPrintableCostCenters: 'Yazdırma için kullanılabilir etkin bir masraf merkezi yok. Bir yöneticiden yazdırma erişimi vermesini isteyin.',
   },
   },
 
 
   // Yedekleme
   // Yedekleme
@@ -5748,6 +5860,8 @@ export default {
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
     missingSpoolAssignmentLabel: 'Eksik Makara Ataması',
     missingSpoolAssignmentLabel: 'Eksik Makara Ataması',
+    billingChargeFailedLabel: 'Ücretlendirme hatası',
+    billingChargeFailedDescription: 'Baskı maliyetleri kaydedilemediğinde bildirim gönder',
     missingSpoolAssignmentDescription: 'Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir',
     missingSpoolAssignmentDescription: 'Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir',
     printFailed: 'Baskı Başarısız',
     printFailed: 'Baskı Başarısız',
     printStopped: 'Baskı Durduruldu',
     printStopped: 'Baskı Durduruldu',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: "Технічне обслуговування",
     maintenance: "Технічне обслуговування",
     projects: "Проєкти",
     projects: "Проєкти",
     inventory: "Філамент",
     inventory: "Філамент",
+    finance: "Фінанси",
     files: "Менеджер файлів",
     files: "Менеджер файлів",
     makerworld: "MakerWorld",
     makerworld: "MakerWorld",
     notifications: "Сповіщення",
     notifications: "Сповіщення",
@@ -124,6 +125,98 @@ export default {
     duplicate: "Дублювати",
     duplicate: "Дублювати",
     left: "Ліворуч",
     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: "Сторінка {{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 page
@@ -359,6 +452,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: "Принтер видалено",
       printerDeleted: "Принтер видалено",
       missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
       missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
+      killSwitchTriggered: 'Аварійний вимикач білінгу зупинив несанкціонований друк на {{printer}}: {{filename}}',
+      billingChargeFailed: 'Не вдалося нарахувати вартість {{filename}} на {{printer}}. Резерв бюджету збережено; перевірте журнали сервера.',
       assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
       assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
       assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
       assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
       assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
       assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
@@ -2313,6 +2408,20 @@ export default {
     },
     },
     externalCameras: "Зовнішні камери",
     externalCameras: "Зовнішні камери",
     costTracking: "Відстеження витрат",
     costTracking: "Відстеження витрат",
+    billingEnabled: "Увімкнути розрахунки",
+    billingEnabledDescription: "Стягувати з користувачів вартість друку та ввімкнути фінансові функції",
+    printerKillSwitch: "Автозупинка несанкціонованого друку",
+    printerKillSwitchDescription: "Негайно зупиняє друк, розпочатий без дозволу.",
+    financeBudgetReset: "Щомісячне скидання фінансового бюджету",
+    financeBudgetResetDay: "День скидання",
+    financeBudgetResetDayHelp: "У коротких місяцях скидання виконується в останній день місяця.",
+    financeBudgetResetTimezone: "Часовий пояс скидання",
+    financeBudgetResetTimezoneHelp: "Початок бюджетного періоду обчислюється в цьому часовому поясі.",
+    rebuildLedger: "Перебудувати книгу гаманця",
+    rebuildLedgerStarted: "Перебудову книги розпочато",
+    rebuildLedgerConfirmTitle: "Перебудувати книгу гаманця?",
+    rebuildLedgerConfirmMessage: "Книгу гаманця буде перебудовано для виправлення історичних значень балансу. Запускайте цю дію лише якщо розумієте її наслідки.",
+    rebuildLedgerInProgress: "Запуск перебудови...",
     printsOnly: "Лише друк",
     printsOnly: "Лише друк",
     totalConsumption: "Загальне споживання",
     totalConsumption: "Загальне споживання",
     dataManagement: "Управління даними",
     dataManagement: "Управління даними",
@@ -4842,6 +4951,9 @@ export default {
     staggerTotal: "всього: {{minutes}} хв",
     staggerTotal: "всього: {{minutes}} хв",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     gcodeInjection: "Додати G-код автоматичного друку",
     gcodeInjection: "Додати G-код автоматичного друку",
+    insufficientBudget: "Недостатньо бюджету",
+    unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.",
+    noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.",
   },
   },
 
 
   // Backup
   // Backup
@@ -5835,6 +5947,8 @@ export default {
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
     missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
     missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
+    billingChargeFailedLabel: 'Помилка списання',
+    billingChargeFailedDescription: 'Сповіщати, якщо не вдалося облікувати вартість друку',
     missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
     missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
     printFailed: "Помилка друку",
     printFailed: "Помилка друку",
     printStopped: "Друк зупинено",
     printStopped: "Друк зупинено",

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

@@ -8,6 +8,7 @@ export default {
     profiles: '配置文件',
     profiles: '配置文件',
     maintenance: '维护',
     maintenance: '维护',
     projects: '项目',
     projects: '项目',
+    finance: '财务',
     inventory: '耗材',
     inventory: '耗材',
     files: '文件管理器',
     files: '文件管理器',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: '操作',
     actions: '操作',
     status: '状态',
     status: '状态',
     name: '名称',
     name: '名称',
+    user: '用户',
     description: '描述',
     description: '描述',
     date: '日期',
     date: '日期',
     time: '时间',
     time: '时间',
@@ -124,6 +126,97 @@ export default {
     duplicate: '复制',
     duplicate: '复制',
     left: '左',
     left: '左',
     right: '右',
     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
   // Printers page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: '打印机已删除',
       printerDeleted: '打印机已删除',
       missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
       missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
+      killSwitchTriggered: '计费终止开关已停止 {{printer}} 上的未授权打印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 计费失败。预算预留已保留;请检查服务器日志。',
       assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
       assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
       assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
       assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
@@ -2313,6 +2408,21 @@ export default {
     useTls: '使用 TLS',
     useTls: '使用 TLS',
     enableMetricsEndpoint: '启用指标端点',
     enableMetricsEndpoint: '启用指标端点',
     availableMetrics: '可用指标',
     availableMetrics: '可用指标',
+    billingEnabled: '启用账单',
+    billingEnabledDescription: '对用户的打印进行收费并启用财务功能',
+    printerKillSwitch: '打印机紧急停止开关',
+    printerKillSwitchDescription: '立即停止未经授权的打印',
+    financeBudgetReset: '财务预算月度重置',
+    financeBudgetResetDay: '重置日期',
+    financeBudgetResetDayHelp: '对于少数月份,重置使用该月的最后一天。',
+    financeBudgetResetTimezone: '重置时区',
+    financeBudgetResetTimezoneHelp: '预算窗口的开始时间在此时区计算。',
+    rebuildLedger: '重建钱包账本',
+    rebuildLedgerStarted: '钱包账本重建已启动',
+    rebuildLedgerConfirmTitle: '重建钱包账本?',
+    rebuildLedgerConfirmMessage:
+      '这将重建钱包账本以修复历史余额值。仅在您知道自己在做什么时才执行此操作。',
+    rebuildLedgerInProgress: '正在启动重建...',
     editUser: '编辑用户',
     editUser: '编辑用户',
     deleteUserTitle: '删除用户',
     deleteUserTitle: '删除用户',
     groupName: '组名称',
     groupName: '组名称',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: '共 {{minutes}} 分钟',
     staggerTotal: '共 {{minutes}} 分钟',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     gcodeInjection: '注入自动打印G-code',
     gcodeInjection: '注入自动打印G-code',
+    insufficientBudget: '预算不足',
+    unlimitedNoBudget: '无限制 – 未设置预算上限。',
+    noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。',
   },
   },
 
 
   // Backup
   // Backup
@@ -5780,6 +5893,8 @@ export default {
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '计费失败',
+    billingChargeFailedDescription: '无法记录打印费用时通知',
     missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
     printFailed: '打印失败',
     printFailed: '打印失败',
     printStopped: '打印已停止',
     printStopped: '打印已停止',

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

@@ -8,6 +8,7 @@ export default {
     profiles: '設定檔案',
     profiles: '設定檔案',
     maintenance: '維護',
     maintenance: '維護',
     projects: '專案',
     projects: '專案',
+    finance: '財務',
     inventory: '耗材',
     inventory: '耗材',
     files: '檔案管理器',
     files: '檔案管理器',
     makerworld: 'MakerWorld',
     makerworld: 'MakerWorld',
@@ -124,6 +125,98 @@ export default {
     duplicate: '複製',
     duplicate: '複製',
     left: '左',
     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: '第 {{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 page
@@ -356,6 +449,8 @@ export default {
     toast: {
     toast: {
       printerDeleted: '印表機已刪除',
       printerDeleted: '印表機已刪除',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
       missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
+      killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
+      billingChargeFailed: '{{printer}} 上的 {{filename}} 計費失敗。預算保留已保留;請檢查伺服器記錄。',
       assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
       assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
       assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
       assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
       assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
@@ -2313,6 +2408,21 @@ export default {
     useTls: '使用 TLS',
     useTls: '使用 TLS',
     enableMetricsEndpoint: '啟用指標端點',
     enableMetricsEndpoint: '啟用指標端點',
     availableMetrics: '可用指標',
     availableMetrics: '可用指標',
+    billingEnabled: '啟用帳單',
+    billingEnabledDescription: '向用戶收取列印費用並啟用財務功能',
+    printerKillSwitch: '列印機緊急停止開關',
+    printerKillSwitchDescription: '立即停止未經授權的列印',
+    financeBudgetReset: '財務預算月度重置',
+    financeBudgetResetDay: '重置日期',
+    financeBudgetResetDayHelp: '對於少數月份,重置使用該月的最後一天。',
+    financeBudgetResetTimezone: '重置時區',
+    financeBudgetResetTimezoneHelp: '預算視窗的開始時間在此時區計算。',
+    rebuildLedger: '重建錢包帳本',
+    rebuildLedgerStarted: '錢包帳本重建已啟動',
+    rebuildLedgerConfirmTitle: '重建錢包帳本?',
+    rebuildLedgerConfirmMessage:
+      '這將重建錢包帳本以修復歷史餘額值。僅在您知道自己在做什麼時才執行此操作。',
+    rebuildLedgerInProgress: '正在啟動重建...',
     editUser: '編輯使用者',
     editUser: '編輯使用者',
     deleteUserTitle: '刪除使用者',
     deleteUserTitle: '刪除使用者',
     groupName: '群組名稱',
     groupName: '群組名稱',
@@ -4787,6 +4897,9 @@ export default {
     staggerTotal: '總計:{{minutes}} 分鐘',
     staggerTotal: '總計:{{minutes}} 分鐘',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     gcodeInjection: '注入自動列印G-code',
     gcodeInjection: '注入自動列印G-code',
+    insufficientBudget: '預算不足',
+    unlimitedNoBudget: '無限制 – 未設定預算上限。',
+    noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
   },
   },
 
 
   // Backup
   // Backup
@@ -5780,6 +5893,8 @@ export default {
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
     missingSpoolAssignmentLabel: '缺少料卷分配',
     missingSpoolAssignmentLabel: '缺少料卷分配',
+    billingChargeFailedLabel: '計費失敗',
+    billingChargeFailedDescription: '無法記錄列印費用時通知',
     missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
     missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
     printFailed: '列印失敗',
     printFailed: '列印失敗',
     printStopped: '列印已停止',
     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 [templateFilter, setTemplateFilter] = useState('');
   const [settingsSearch, setSettingsSearch] = useState('');
   const [settingsSearch, setSettingsSearch] = useState('');
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [showLogViewer, setShowLogViewer] = useState(false);
+  const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
+  const [isRebuildLoading, setIsRebuildLoading] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
 
 
   // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
   // 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.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
       (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
       (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
       (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_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) {
     if (!hasChanges) {
       return;
       return;
@@ -1190,6 +1196,10 @@ export function SettingsPage() {
         chamber_temp_presets: localSettings.chamber_temp_presets,
         chamber_temp_presets: localSettings.chamber_temp_presets,
         fan_speed_presets: localSettings.fan_speed_presets,
         fan_speed_presets: localSettings.fan_speed_presets,
         session_max_hours: localSettings.session_max_hours,
         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);
       updateMutation.mutate(settingsToSave);
     }, 500);
     }, 500);
@@ -2309,6 +2319,151 @@ export function SettingsPage() {
                     : t('settings.energyModeTotalDescription')}
                     : t('settings.energyModeTotalDescription')}
                 </p>
                 </p>
               </div>
               </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>
             </CardContent>
           </Card>
           </Card>