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

Merge remote-tracking branch 'upstream/dev' into feature/billing

maziggy 3 недель назад
Родитель
Сommit
12c6749abe
56 измененных файлов с 8789 добавлено и 23 удалено
  1. 6 0
      backend/app/api/routes/auth.py
  2. 1085 0
      backend/app/api/routes/finance.py
  3. 57 0
      backend/app/api/routes/print_queue.py
  4. 3 0
      backend/app/api/routes/settings.py
  5. 5 0
      backend/app/api/routes/users.py
  6. 5 0
      backend/app/core/auth.py
  7. 478 8
      backend/app/core/database.py
  8. 14 0
      backend/app/core/permissions.py
  9. 166 1
      backend/app/main.py
  10. 6 0
      backend/app/models/archive.py
  11. 161 0
      backend/app/models/finance.py
  12. 7 1
      backend/app/models/print_queue.py
  13. 118 0
      backend/app/schemas/finance.py
  14. 8 0
      backend/app/schemas/print_queue.py
  15. 24 0
      backend/app/schemas/settings.py
  16. 2 0
      backend/app/services/archive.py
  17. 4 3
      backend/app/services/bambu_mqtt.py
  18. 231 0
      backend/app/services/finance_billing.py
  19. 285 0
      backend/app/services/finance_budget.py
  20. 68 0
      backend/app/services/finance_defaults.py
  21. 96 0
      backend/app/services/print_scheduler.py
  22. 4 0
      backend/tests/integration/test_auth_apikey_rbac.py
  23. 799 0
      backend/tests/integration/test_finance_api.py
  24. 94 0
      backend/tests/integration/test_ldap_provision.py
  25. 5 0
      backend/tests/integration/test_print_lifecycle.py
  26. 297 0
      backend/tests/integration/test_print_queue_api.py
  27. 246 0
      backend/tests/integration/test_scheduler_budget_reservation.py
  28. 559 0
      backend/tests/unit/services/test_finance_service_billing.py
  29. 72 0
      backend/tests/unit/services/test_finance_service_defaults.py
  30. 134 0
      backend/tests/unit/test_finance_table_migration.py
  31. 205 0
      backend/tests/unit/test_printer_kill_switch.py
  32. 1 0
      backend/tests/unit/test_scheduler_cleanup_library.py
  33. 2 0
      frontend/src/App.tsx
  34. 195 0
      frontend/src/__tests__/components/PrintModalBilling.test.tsx
  35. 3 2
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  36. 194 0
      frontend/src/api/client.ts
  37. 3 1
      frontend/src/components/Layout.tsx
  38. 38 0
      frontend/src/components/PrintModal/CostCenterSelect.tsx
  39. 29 0
      frontend/src/components/PrintModal/FilamentMapping.tsx
  40. 68 2
      frontend/src/components/PrintModal/index.tsx
  41. 3 0
      frontend/src/components/PrintModal/types.ts
  42. 108 1
      frontend/src/i18n/locales/de.ts
  43. 108 1
      frontend/src/i18n/locales/en.ts
  44. 106 0
      frontend/src/i18n/locales/es.ts
  45. 107 0
      frontend/src/i18n/locales/fr.ts
  46. 107 0
      frontend/src/i18n/locales/it.ts
  47. 107 0
      frontend/src/i18n/locales/ja.ts
  48. 107 2
      frontend/src/i18n/locales/ko.ts
  49. 107 0
      frontend/src/i18n/locales/pt-BR.ts
  50. 105 0
      frontend/src/i18n/locales/ru.ts
  51. 106 0
      frontend/src/i18n/locales/tr.ts
  52. 106 0
      frontend/src/i18n/locales/uk.ts
  53. 107 0
      frontend/src/i18n/locales/zh-CN.ts
  54. 107 0
      frontend/src/i18n/locales/zh-TW.ts
  55. 1465 0
      frontend/src/pages/FinancePage.tsx
  56. 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)

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

@@ -0,0 +1,1085 @@
+import calendar
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import and_, 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.archive import PrintArchive
+from backend.app.models.finance import (
+    BudgetReservation,
+    CostCenter,
+    CostCenterMember,
+    TransactionType,
+    UserWallet,
+    WalletTransaction,
+    normalize_transaction_type,
+)
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.finance import (
+    CostCenterBudgetUpdateRequest,
+    CostCenterCreateRequest,
+    CostCenterDetailResponse,
+    CostCenterMemberRequest,
+    CostCenterMemberResponse,
+    CostCenterSummaryResponse,
+    CostCenterUpdateRequest,
+    ManualPrintRequest,
+    TransactionEditRequest,
+    WalletAdjustmentRequest,
+    WalletAdjustmentResponse,
+    WalletBalanceResponse,
+    WalletTransactionListResponse,
+    WalletTransactionResponse,
+)
+
+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),
+        )
+        .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.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),
+        )
+        .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]:
+    if not cost_center_ids:
+        return {}
+
+    budget_rows = await db.execute(
+        select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
+        .where(
+            BudgetReservation.cost_center_id.in_(cost_center_ids),
+            BudgetReservation.status == "active",
+        )
+        .group_by(BudgetReservation.cost_center_id)
+    )
+    reserved_map = {int(center_id): float(value) for center_id, value in budget_rows.all() if center_id is not None}
+
+    queue_rows = await db.execute(
+        select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
+        .where(
+            PrintQueueItem.cost_center_id.in_(cost_center_ids),
+            PrintQueueItem.status.in_(("pending", "printing")),
+        )
+        .group_by(PrintQueueItem.cost_center_id)
+    )
+    for center_id, value in queue_rows.all():
+        if center_id is not None:
+            reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
+    return reserved_map
+
+
+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 _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[int, float]:
+    result = await db.execute(
+        select(
+            WalletTransaction.id,
+            WalletTransaction.amount,
+            WalletTransaction.cost_center_id,
+            CostCenter.is_private,
+            CostCenter.owner_user_id,
+        )
+        .where(
+            WalletTransaction.user_id == user_id,
+        )
+        .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
+        .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+    )
+
+    running_balance = 0.0
+    balance_map: dict[int, float] = {}
+    for transaction_id, amount, cost_center_id, is_private, owner_user_id in result.all():
+        is_personal_cost_center = bool(cost_center_id is not None and is_private and owner_user_id == user_id)
+        if cost_center_id is None or is_personal_cost_center:
+            running_balance += float(amount)
+            balance_map[int(transaction_id)] = running_balance
+
+    return balance_map
+
+
+def _personal_balance_condition(user_id: int):
+    return or_(
+        WalletTransaction.cost_center_id.is_(None),
+        and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
+    )
+
+
+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)
+
+    # 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")
+        wallet.balance = new_balance
+        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,
+            )
+        )
+        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
+        # Do NOT update wallet.balance for cost-center transactions
+
+    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 db.commit()
+    await db.refresh(wallet)
+    await db.refresh(tx)
+
+    # Return appropriate balance based on transaction type
+    if cost_center_id is None:
+        # 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)
+    wallet = await _get_or_create_wallet(db, user.id)
+    personal_balance_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, _personal_balance_condition(user.id))
+    )
+    personal_balance = float(personal_balance_result.scalar_one() or 0.0)
+    return WalletBalanceResponse(
+        user_id=user.id,
+        balance=personal_balance,
+        currency=wallet.currency,
+        updated_at=wallet.updated_at,
+    )
+
+
+@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)
+    )
+    total = int(total_result.scalar_one() or 0)
+
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(WalletTransaction.user_id == user.id)
+        .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)
+    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 = []
+    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:
+    """Recompute `balance_after` for all wallet transactions of a user.
+
+    - Personal transactions (cost_center_id=None): running balance per user
+    - Cost-center transactions: running balance GLOBAL for entire cost center (not per-user)
+    - Also updates the user's wallet balance (sum of personal transactions only)
+    """
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(WalletTransaction.user_id == user_id)
+        .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+    )
+    user_transactions = result.scalars().all()
+
+    # Handle personal transactions (cost_center_id=None)
+    personal_balance = 0.0
+    for tx in user_transactions:
+        if tx.cost_center_id is None:
+            personal_balance += float(tx.amount)
+            tx.balance_after = personal_balance
+            db.add(tx)
+
+    for cc_id in {tx.cost_center_id for tx in user_transactions if tx.cost_center_id is not None}:
+        # Get all transactions for this cost center (all users, all time)
+        result_all_cc = await db.execute(
+            select(WalletTransaction)
+            .where(WalletTransaction.cost_center_id == cc_id)
+            .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+        )
+        all_cc_transactions = result_all_cc.scalars().all()
+
+        running = 0.0
+        for tx in all_cc_transactions:
+            running += float(tx.amount)
+            tx.balance_after = running
+            db.add(tx)
+
+    # Update user wallet balance (sum of all personal transactions only)
+    wallet = await _get_or_create_wallet(db, user_id)
+    wallet.balance = personal_balance
+    await db.flush()
+    await db.commit()
+
+
+@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))
+    tx = result.scalar_one_or_none()
+    if tx is None:
+        raise HTTPException(status_code=404, detail="Transaction not found")
+
+    user_id = tx.user_id
+
+    if tx.transaction_type == "print_charge" and tx.print_archive_id is not None:
+        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == tx.print_archive_id))
+        archive = archive_result.scalar_one_or_none()
+        if archive is not None:
+            archive.wallet_charge_skipped = True
+            db.add(archive)
+
+    await db.delete(tx)
+    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))
+    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:
+        tx.user_id = request.user_id
+
+    if request.cost_center_id is not None:
+        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)
+
+    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)
+
+    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)
+
+    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)
+    wallet = await _get_or_create_wallet(db, user.id)
+    return _to_balance_response(wallet)
+
+
+@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)
+        .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: per-user running balance
+    - Cost-center transactions: global running balance for the entire cost center
+    """
+    await _require_authenticated_user(current_user)
+
+    # Get ALL transactions sorted by timestamp
+    result = await db.execute(
+        select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+    )
+    all_transactions = result.scalars().all()
+
+    # Build running balances per (user, cost_center_id) pair
+    # For each cost center, track its global running balance
+    # For each user's personal balance, track that separately
+    cc_running_balances: dict[int, float] = {}  # cost_center_id -> running balance
+    user_personal_balances: dict[int, float] = {}  # user_id -> personal running balance
+
+    tx_updates: list[tuple[WalletTransaction, float]] = []
+
+    for tx in all_transactions:
+        if tx.cost_center_id is None:
+            # Personal transaction: per-user running balance
+            current = user_personal_balances.get(tx.user_id, 0.0)
+            new_balance = current + float(tx.amount)
+            user_personal_balances[tx.user_id] = new_balance
+            tx_updates.append((tx, new_balance))
+        else:
+            # Cost-center transaction: global running balance for this cost center
+            current = cc_running_balances.get(tx.cost_center_id, 0.0)
+            new_balance = current + float(tx.amount)
+            cc_running_balances[tx.cost_center_id] = new_balance
+            tx_updates.append((tx, new_balance))
+
+    # Update all transactions with the new balance_after values
+    for tx, new_balance in tx_updates:
+        tx.balance_after = new_balance
+        db.add(tx)
+
+    await db.flush()
+    await db.commit()
+
+    return {
+        "status": "success",
+        "transactions_rebuilt": len(all_transactions),
+        "message": f"Rebuilt balance_after for {len(all_transactions)} transactions",
+    }
+
+
+@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 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")
+
+    balance_map = await _get_cost_center_balance_map(db, [center.id])
+    total_balance = balance_map.get(center.id, 0.0)
+    if abs(total_balance) > 1e-9:
+        raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
+
+    await db.delete(center)
+    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"}

+ 57 - 0
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,
@@ -333,6 +334,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 +945,14 @@ 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")
 
 
+    await validate_print_budget(
+        db,
+        cost_center_id=data.cost_center_id,
+        estimated_cost=data.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 +1009,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=data.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 +1146,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,6 +1161,15 @@ async def bulk_update_queue_items(
             skipped_count += 1
             skipped_count += 1
             continue
             continue
 
 
+        if validates_billing_fields:
+            await validate_print_budget(
+                db,
+                cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
+                estimated_cost=update_data.get("estimated_cost", item.estimated_cost),
+                current_user=user,
+                exclude_queue_item_id=item.id,
+            )
+
         for field, value in update_data.items():
         for field, value in update_data.items():
             setattr(item, field, value)
             setattr(item, field, value)
         updated_count += 1
         updated_count += 1
@@ -1552,6 +1575,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 +1826,14 @@ 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
         )
         )
 
 
+    await validate_print_budget(
+        db,
+        cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
+        estimated_cost=update_data.get("estimated_cost", item.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 +1881,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 +1999,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 +2169,14 @@ 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}'")
 
 
+    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,

+ 478 - 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,213 @@ 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``.  The invitation table is kept
+    as a compatibility table for pre-release billing databases; current code
+    does not map or query it.
+    """
+    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 FLOAT,
+                monthly_budget FLOAT,
+                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 FLOAT 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 cost_center_invitations (
+                id INTEGER PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                email VARCHAR(320) NOT NULL,
+                token VARCHAR(255) NOT NULL UNIQUE,
+                invited_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                expires_at DATETIME,
+                accepted_at DATETIME,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            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 FLOAT NOT NULL,
+                balance_after FLOAT,
+                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,
+                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 FLOAT 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 FLOAT,
+                monthly_budget FLOAT,
+                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 FLOAT 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 cost_center_invitations (
+                id SERIAL PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                email VARCHAR(320) NOT NULL,
+                token VARCHAR(255) NOT NULL UNIQUE,
+                invited_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                expires_at TIMESTAMP,
+                accepted_at TIMESTAMP,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            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 FLOAT NOT NULL,
+                balance_after FLOAT,
+                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,
+                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 FLOAT 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."""
+    indexes = [
+        "CREATE 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_cost_center_invitations_cost_center_id "
+        "ON cost_center_invitations (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_center_invitations_email ON cost_center_invitations (email)",
+        "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 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 +1246,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 +1271,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 +1311,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 FLOAT")
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget FLOAT")
+    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 +1358,14 @@ 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_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 +1410,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 +1574,17 @@ 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 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 +1697,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 +1721,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 +1944,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 +1953,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 +1976,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 +2956,61 @@ 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")
+
+    # Migration: Persist active budget reservations for accepted background dispatch jobs.
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            """
+            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 FLOAT NOT NULL,
+                status VARCHAR(20),
+                source_type VARCHAR(50),
+                source_id INTEGER,
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                released_at DATETIME
+            )
+            """,
+        )
+    else:
+        await _safe_execute(
+            conn,
+            """
+            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 FLOAT NOT NULL,
+                status VARCHAR(20),
+                source_type VARCHAR(50),
+                source_id INTEGER,
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                released_at TIMESTAMP
+            )
+            """,
+        )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id ON budget_reservations (cost_center_id)"
+    )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)"
+    )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)"
+    )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)"
+    )
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id ON budget_reservations (print_archive_id)",
+    )
+
     # 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 +4593,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 +4645,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 +4906,89 @@ 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():
+    """Repair wallet ledger balance_after values.
+
+    This is called during database initialization to ensure all balance_after
+    values are correct after code changes. Fixes old balance_after values to:
+    - Personal transactions: per-user running balance
+    - Cost-center transactions: global running balance for the entire cost center
+    Also updates UserWallet.balance to match final personal transaction balance.
+    """
+    from sqlalchemy import select
+
+    from backend.app.models.finance import WalletTransaction
+
+    async with async_session() as session:
+        # Check if there are any transactions first
+        result = await session.execute(select(WalletTransaction).limit(1))
+        if not result.scalar_one_or_none():
+            logger.info("No wallet transactions found, skipping ledger rebuild")
+            return
+
+        updated_count = await repair_wallet_ledger_internal(session)
+        await session.commit()
+
+        if updated_count > 0:
+            logger.info("Repaired wallet ledger: updated %d transactions/wallets", updated_count)
+
+
+async def repair_wallet_ledger_internal(session: AsyncSession):
+    """Internal helper that repairs wallet ledger using an existing session.
+
+    Used by API endpoints that need to rebuild the ledger within their own transaction.
+    """
+    from sqlalchemy import select
+
+    from backend.app.models.finance import UserWallet, WalletTransaction
+
+    # Get ALL transactions sorted by timestamp
+    result = await session.execute(
+        select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+    )
+    all_transactions = result.scalars().all()
+
+    if not all_transactions:
+        return 0
+
+    # 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
+
+    tx_updates: list[tuple[WalletTransaction, float]] = []
+
+    for tx in all_transactions:
+        if tx.cost_center_id is None:
+            # Personal transaction: per-user running balance
+            current = user_personal_balances.get(tx.user_id, 0.0)
+            new_balance = current + float(tx.amount)
+            user_personal_balances[tx.user_id] = new_balance
+            tx_updates.append((tx, new_balance))
+        else:
+            # Cost-center transaction: global running balance for this cost center
+            current = cc_running_balances.get(tx.cost_center_id, 0.0)
+            new_balance = current + float(tx.amount)
+            cc_running_balances[tx.cost_center_id] = new_balance
+            tx_updates.append((tx, new_balance))
+
+    # Update all transactions with the new balance_after values
+    updated_count = 0
+    for tx, new_balance in tx_updates:
+        if tx.balance_after != new_balance:
+            tx.balance_after = new_balance
+            session.add(tx)
+            updated_count += 1
+
+    # Update UserWallet balances to match final personal balances
+    for user_id, balance in user_personal_balances.items():
+        wallet_result = await session.execute(select(UserWallet).where(UserWallet.user_id == user_id))
+        wallet = wallet_result.scalar_one_or_none()
+        if wallet and 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

+ 166 - 1
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,
@@ -404,6 +405,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
@@ -418,6 +422,9 @@ _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()
+
 # 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]] = {}
@@ -643,6 +650,54 @@ _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(printer_id: int, state: PrinterState) -> bool:
+    """Return True when the current status belongs to a print started by Bambuddy."""
+
+    if printer_manager.get_current_print_user(printer_id):
+        return True
+
+    possible_keys = _build_status_print_keys(printer_id, state)
+    return any(key in _expected_prints or key in _active_prints for key in possible_keys)
+
 
 
 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).
@@ -715,6 +770,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."""
@@ -728,6 +784,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).
@@ -1317,6 +1375,45 @@ 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)
+    else:
+        kill_switch_enabled = False
+        status_logger = logging.getLogger(__name__)
+        try:
+            async with async_session() as db:
+                from backend.app.services.finance_budget import is_printer_kill_switch_enabled
+
+                kill_switch_enabled = await is_printer_kill_switch_enabled(db)
+        except Exception as e:
+            status_logger.warning("[KILL SWITCH] Failed to read kill-switch setting for printer %s: %s", printer_id, e)
+
+        if not kill_switch_enabled or _is_bambuddy_authorized_print(printer_id, state):
+            _unauthorized_print_kill_sent.discard(printer_id)
+        elif printer_id in _unauthorized_print_kill_sent:
+            pass
+        else:
+            try:
+                stopped = printer_manager.stop_print(printer_id)
+                if stopped:
+                    _unauthorized_print_kill_sent.add(printer_id)
+                    status_logger.warning(
+                        "[KILL SWITCH] Stopped unauthorized print on printer %s (state=%s)",
+                        printer_id,
+                        state.state,
+                    )
+                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)
@@ -5192,6 +5289,28 @@ 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,
+                    )
+        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] = []
@@ -5414,6 +5533,50 @@ 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 archive.created_by_id is None and _print_user_info:
+                    archive.created_by_id = _print_user_info.get("user_id")
+                    await db.flush()
+
+                run_status = data.get("status", "completed")
+                last_progress = data.get("last_progress")
+                if last_progress is None:
+                    last_progress = data.get("progress")
+                actual_run_grams = _compute_run_filament_grams(
+                    run_status,
+                    billing_planned_grams,
+                    last_progress,
+                    usage_results,
+                )
+                filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None
+                cost_center_id = _print_cost_center_ids.pop(archive_id, None)
+                charged = await apply_print_charge_for_archive(
+                    db,
+                    archive_id,
+                    cost_center_id=cost_center_id,
+                    print_run_id=archive.subtask_id if archive else None,
+                    base_cost_override=billing_base_cost,
+                    filament_usage=filament_usage,
+                )
+                await db.commit()
+                if charged:
+                    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)
+
+    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:
@@ -5453,7 +5616,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,
                 )
                 )
 
 
@@ -6894,6 +7057,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(
@@ -7941,6 +8105,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)

+ 6 - 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))
@@ -92,6 +95,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 +126,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

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

@@ -0,0 +1,161 @@
+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, Float, ForeignKey, 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(Float, 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(Float, nullable=True)
+    monthly_budget: Mapped[float | None] = mapped_column(Float, 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(Float)
+    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(Float)
+    balance_after: Mapped[float | None] = mapped_column(Float, 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
+    )
+
+    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)

+ 7 - 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,10 @@ 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)
     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 +166,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 +249,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
+    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 = None
+    monthly_budget: float | None = None
+    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 = None
+    monthly_budget: float | None = None
+
+
+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] = []

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

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

@@ -3189,10 +3189,11 @@ class BambuMQTTClient:
             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)
             # 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
             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

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

@@ -0,0 +1,231 @@
+import logging
+
+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_budget import is_billing_enabled, release_budget_reservation
+
+logger = logging.getLogger(__name__)
+
+
+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,
+                )
+            )
+            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,
+    *,
+    cost_center_id: int | None = None,
+    print_run_id: str | None = None,
+    base_cost_override: float | None = None,
+    filament_usage: tuple[float | None, float | None] | None = None,
+) -> bool:
+    """Apply an idempotent wallet charge for a print archive.
+
+    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):
+            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
+
+        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
+
+        if archive.created_by_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
+
+        tx_conditions = [WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value]
+        if print_run_id:
+            tx_conditions.append(WalletTransaction.print_run_id == print_run_id)
+        else:
+            tx_conditions.append(WalletTransaction.print_archive_id == archive.id)
+
+        existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
+        if existing_tx is not None:
+            logger.info(f"Transaction already exists for archive ID {archive_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:
+            await release_budget_reservation(db, print_archive_id=archive.id, status="released")
+            logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
+            return False
+
+        actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
+
+        wallet = (
+            await db.execute(select(UserWallet).where(UserWallet.user_id == archive.created_by_id))
+        ).scalar_one_or_none()
+        if wallet is None:
+            wallet = UserWallet(user_id=archive.created_by_id, balance=0.0, currency="EUR")
+            db.add(wallet)
+            await db.flush()
+            logger.info(f"Created new wallet for user ID {archive.created_by_id}.")
+
+        # Persist wallet balances rounded to cents
+        new_wallet_balance = round(float(wallet.balance) - charge, 2)
+        wallet.balance = new_wallet_balance
+
+        label = archive.print_name or archive.filename or f"Archive {archive.id}"
+        description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
+
+        balance_after = await _get_balance_after_for_transaction(
+            db, archive.created_by_id, actual_cost_center_id, -charge
+        )
+        if balance_after is not None:
+            balance_after = round(float(balance_after), 2)
+
+        tx = WalletTransaction(
+            user_id=archive.created_by_id,
+            cost_center_id=actual_cost_center_id,
+            transaction_type=TransactionType.PRINT_CHARGE.value,
+            amount=-charge,
+            balance_after=balance_after,
+            description=description,
+            created_by_user_id=None,
+            print_run_id=print_run_id or archive.subtask_id,
+            print_archive_id=archive.id,
+        )
+        db.add(tx)
+        # Ensure the transaction is flushed to detect unique/index constraint violations
+        try:
+            await db.flush()
+        except IntegrityError as e:
+            # Another concurrent worker likely created the same transaction
+            logger.info("Transaction already exists for archive ID %s (concurrent), skipping: %s", archive_id, e)
+            await db.rollback()
+            return False
+
+        # Consume matching budget reservations after the transaction is persisted
+        await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
+        logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
+        return True
+    except SQLAlchemyError as e:
+        logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
+        return False
+    except ValueError as e:
+        logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
+        return False

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

@@ -0,0 +1,285 @@
+"""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),
+    ]
+    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 _cost_center_open_queue_reservations(
+    db: AsyncSession,
+    cost_center_id: int,
+    *,
+    exclude_queue_item_id: int | None = None,
+) -> float:
+    active_queue_reservation = (
+        select(BudgetReservation.id)
+        .where(
+            BudgetReservation.status == "active",
+            BudgetReservation.source_type == "print_queue",
+            BudgetReservation.source_id == PrintQueueItem.id,
+        )
+        .exists()
+    )
+    conditions = [
+        PrintQueueItem.cost_center_id == cost_center_id,
+        PrintQueueItem.status.in_(("pending", "printing")),
+        ~active_queue_reservation,
+    ]
+    if exclude_queue_item_id is not None:
+        conditions.append(PrintQueueItem.id != exclude_queue_item_id)
+
+    result = await db.execute(select(func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0)).where(*conditions))
+    return float(result.scalar() or 0.0)
+
+
+async def _cost_center_active_budget_reservations(
+    db: AsyncSession,
+    cost_center_id: int,
+    *,
+    exclude_source_type: str | None = None,
+    exclude_source_id: int | None = None,
+) -> float:
+    conditions = [
+        BudgetReservation.cost_center_id == cost_center_id,
+        BudgetReservation.status == "active",
+    ]
+    if exclude_source_type is not None and exclude_source_id is not None:
+        conditions.append(
+            ~(
+                (BudgetReservation.source_type == exclude_source_type)
+                & (BudgetReservation.source_id == exclude_source_id)
+            )
+        )
+    result = await db.execute(select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(*conditions))
+    return float(result.scalar() or 0.0)
+
+
+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 = await _cost_center_open_queue_reservations(
+        db,
+        cost_center_id,
+        exclude_queue_item_id=exclude_queue_item_id,
+    )
+    reserved += await _cost_center_active_budget_reservations(
+        db,
+        cost_center_id,
+        exclude_source_type=exclude_reservation_source_type,
+        exclude_source_id=exclude_reservation_source_id,
+    )
+    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)

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

@@ -0,0 +1,68 @@
+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
+    elif 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

+ 96 - 0
backend/app/services/print_scheduler.py

@@ -8,6 +8,7 @@ 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,6 +36,11 @@ 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.printer_manager import (
 from backend.app.services.printer_manager import (
@@ -504,6 +510,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."""
@@ -1281,6 +1292,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_db, 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
@@ -1312,6 +1328,38 @@ class PrintScheduler:
                 exc_info=True,
                 exc_info=True,
             )
             )
 
 
+    async def _release_unconfirmed_budget_reservation(self, db: AsyncSession, item_id: int) -> None:
+        """Release a queue reservation when dispatch ended before MQTT send."""
+        if item_id not in self._unconfirmed_budget_reservations:
+            return
+
+        for attempt in range(1, 4):
+            try:
+                await db.rollback()
+                await release_budget_reservation(
+                    db,
+                    source_type="print_queue",
+                    source_id=item_id,
+                    status="released",
+                )
+                await db.commit()
+                self._unconfirmed_budget_reservations.discard(item_id)
+                return
+            except Exception as exc:
+                try:
+                    await db.rollback()
+                except Exception:
+                    pass
+                if attempt == 3:
+                    logger.error(
+                        "Queue item %s: failed to release budget reservation after %d attempts: %s",
+                        item_id,
+                        attempt,
+                        exc,
+                    )
+                    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.
 
 
@@ -3628,6 +3676,43 @@ 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
+            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()
@@ -3748,11 +3833,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)
@@ -4068,6 +4156,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
@@ -4204,6 +4293,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
@@ -4575,6 +4665,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"

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

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

@@ -0,0 +1,799 @@
+"""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.models.archive import PrintArchive
+from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+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_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")
+
+        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_wallet_adjustments_and_transaction_ledger_rebuild(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        admin_user,
+        db_session,
+    ):
+        """Test cost-center and personal transaction handling.
+
+        Cost-center transactions affect cost-center balance only.
+        Personal transactions (no cost_center_id) affect user wallet.
+        """
+        await self._enable_basic_user_creation(db_session)
+        created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
+
+        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
+
+        # Deposit to cost center: affects cost-center balance, not user 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": shared_center.id},
+            headers=auth_headers,
+        )
+        assert deposit.status_code == 200
+        assert deposit.json()["transaction"]["cost_center_id"] == shared_center.id
+        assert deposit.json()["transaction"]["balance_after"] == 25.0  # CC balance
+        assert deposit.json()["balance"]["balance"] == 25.0  # Response shows CC balance
+
+        # Withdraw from cost center: affects cost-center balance only
+        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 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"] == 30.0  # Personal balance
+        assert personal_deposit.json()["balance"]["balance"] == 30.0  # User wallet updated
+
+        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"] == shared_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"] == 30.0  # Only personal balance
+
+        # 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"] == 0.0  # Personal balance back to 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_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"] == 12.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
+
+        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_archive_id=archive.id,
+        )
+        db_session.add(tx)
+        await db_session.commit()
+
+        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
+
+        tx_rows_after = (
+            (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
+            .scalars()
+            .all()
+        )
+        assert tx_rows_after == []
+
+    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"]
+
+    @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
+
+
+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

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

+ 297 - 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,149 @@ 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_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": 1.25,
+            },
+        )
+        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_with_cost_center_requires_estimated_cost(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Cost-center queue items require an estimated cost for budget checks."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory()
+        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 == 400
+        assert "Estimated cost is required" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_rejects_when_estimated_cost_exceeds_budget(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Queue creation is rejected if the estimated cost exceeds remaining budget."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory()
+        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": 2.0,
+            },
+        )
+
+        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()
+
+        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_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": 3.0,
+            },
+        )
+
+        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 +337,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()
+        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": 1.25},
+        )
+
+        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 +814,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_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": 3.0,
+            },
+        )
+        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": 3.0,
+            },
+        )
+        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 +988,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 +1706,57 @@ 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, db_session
+    ):
+        """Bulk updates cannot reserve more than the available cost-center budget."""
+        await enable_billing(db_session)
+        item = await queue_item_factory()
+        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": 3.0},
+        )
+
+        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)."""

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

@@ -0,0 +1,246 @@
+"""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
+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()
+
+    # 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_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

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

@@ -0,0 +1,559 @@
+"""Unit tests for billing charges applied to print archives."""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
+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
+
+
+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_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 == -7.5
+
+        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 == -15.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 == -3.0
+
+        transaction = await db_session.scalar(
+            select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
+        )
+        assert transaction is not None
+        assert transaction.amount == -3.0
+        assert status in transaction.description.lower()
+        assert "25.0g/100.0g" in transaction.description
+
+        await db_session.refresh(reservation)
+        assert reservation.status == "consumed"
+        assert reservation.released_at is not None
+
+    @pytest.mark.asyncio
+    async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
+        await enable_billing(db_session)
+        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 == -5.0  # 50% of 10.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 == pytest.approx(-1.0, abs=0.01)  # 5% of 20.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 == -15.0  # Full cost, not proportional
+
+    @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

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

@@ -0,0 +1,72 @@
+"""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

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

@@ -0,0 +1,134 @@
+"""Regression tests for finance tables on upgraded databases."""
+
+import os
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import _migrate_create_finance_indexes, _migrate_create_finance_tables
+
+EXPECTED_TABLES = {
+    "cost_centers",
+    "wallet_transactions",
+    "budget_reservations",
+    "cost_center_members",
+    "cost_center_invitations",
+    "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', 'cost_center_invitations', 'user_wallets')"
+                )
+            )
+
+        assert {row[0] for row in rows} == EXPECTED_TABLES
+    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 _migrate_create_finance_indexes(conn)
+
+            result = await conn.execute(
+                text("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'ix_cost_centers_code'")
+            )
+
+        assert result.scalar_one() == "ix_cost_centers_code"
+    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)
+
+    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_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 _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'"
+                )
+            )
+
+        assert {row[0] for row in rows} == EXPECTED_TABLES
+        assert timestamp_type.scalar_one() == "timestamp without time zone"
+    finally:
+        await engine.dispose()

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

@@ -0,0 +1,205 @@
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app import main as main_module
+
+
+@pytest.fixture(autouse=True)
+def clear_kill_switch_state():
+    main_module._unauthorized_print_kill_sent.clear()
+    main_module._expected_prints.clear()
+    main_module._active_prints.clear()
+    main_module._expected_print_registered_at.clear()
+    yield
+    main_module._unauthorized_print_kill_sent.clear()
+    main_module._expected_prints.clear()
+    main_module._active_prints.clear()
+    main_module._expected_print_registered_at.clear()
+
+
+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] = []
+
+    async def fake_status(*args, **kwargs):
+        return None
+
+    async def kill_switch_enabled(_db):
+        return 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 == [7]
+    assert 7 in main_module._unauthorized_print_kill_sent
+
+
+@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
+
+    async def kill_switch_enabled(_db):
+        return 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
+
+
+@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
+
+    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)
+
+    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",
+        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="",
+        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

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

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

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

@@ -0,0 +1,195 @@
+/**
+ * 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();
+    });
+  });
+});

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

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

@@ -1313,6 +1313,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
@@ -2296,6 +2301,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;
@@ -2453,6 +2460,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
@@ -2529,6 +2538,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 {
@@ -2550,6 +2561,7 @@ 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;
 }
 }
 
 
 export interface PrintQueueBulkUpdateResponse {
 export interface PrintQueueBulkUpdateResponse {
@@ -3565,6 +3577,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'
@@ -3591,6 +3711,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'
@@ -5476,6 +5597,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`),

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

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

@@ -0,0 +1,38 @@
+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;
+
+  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>
+    </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>
           )}
           )}

+ 68 - 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 } = 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(),
@@ -1050,6 +1082,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 +1107,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 +1140,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 +1198,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 {
@@ -1340,6 +1384,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 +1650,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 +1680,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 +1707,14 @@ export function PrintModal({
               />
               />
             )}
             )}
 
 
+            {billingEnabled && printableCostCenters.length > 0 && (
+              <CostCenterSelect
+                costCenters={printableCostCenters}
+                selectedCostCenterId={selectedCostCenterId}
+                onChange={setSelectedCostCenterId}
+              />
+            )}
+
             {/* 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

+ 108 - 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,95 @@ 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: 'Saldo',
+    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: 'Saldo nachher',
+    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
@@ -2294,6 +2385,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',
@@ -3984,7 +4090,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',
@@ -4785,6 +4891,7 @@ 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',
   },
   },
 
 
   // Backup
   // Backup

+ 108 - 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,95 @@ 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: 'Balance',
+    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
@@ -2313,6 +2404,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',
@@ -4013,7 +4119,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',
@@ -4828,6 +4934,7 @@ 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',
   },
   },
 
 
   // Backup
   // Backup

+ 106 - 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,96 @@ 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',
+    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
@@ -2297,6 +2388,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',
@@ -4793,6 +4898,7 @@ 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',
   },
   },
 
 
   // Backup
   // Backup

+ 107 - 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,96 @@ 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',
+    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
@@ -2268,6 +2359,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',
@@ -4774,6 +4880,7 @@ 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',
   },
   },
 
 
   // Backup
   // Backup

+ 107 - 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,96 @@ 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',
+    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
@@ -2268,6 +2359,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',
@@ -4773,6 +4879,7 @@ 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',
   },
   },
 
 
   // Backup
   // Backup

+ 107 - 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,96 @@ 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: '残高',
+    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: {
@@ -2311,6 +2402,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: 'グループ名',
@@ -4785,6 +4891,7 @@ export default {
     staggerTotal: '合計: {{minutes}}分',
     staggerTotal: '合計: {{minutes}}分',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     gcodeInjection: '自動印刷G-codeを挿入',
     gcodeInjection: '自動印刷G-codeを挿入',
+    insufficientBudget: '予算が不足しています',
   },
   },
 
 
   // Backup
   // Backup

+ 107 - 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,96 @@ 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: '잔액',
+    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: '프린터',
@@ -2167,6 +2257,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: '데이터 관리',
@@ -4556,7 +4660,8 @@ export default {
     staggerLastGroup: '마지막 그룹: {{count}}',
     staggerLastGroup: '마지막 그룹: {{count}}',
     staggerTotal: '합계: {{minutes}}분',
     staggerTotal: '합계: {{minutes}}분',
     staggerToPrinters: '{{count}}대 프린터에 분산',
     staggerToPrinters: '{{count}}대 프린터에 분산',
-    gcodeInjection: '자동 인쇄 G-code 삽입'
+    gcodeInjection: '자동 인쇄 G-code 삽입',
+    insufficientBudget: '예산 부족'
   },
   },
   backup: {
   backup: {
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',

+ 107 - 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,96 @@ 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',
+    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
@@ -2268,6 +2359,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',
@@ -4773,6 +4879,7 @@ 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',
   },
   },
 
 
   // Backup
   // Backup

+ 105 - 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,95 @@ 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: "Баланс",
+    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: "Принтеры",
@@ -2168,6 +2258,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: "Управление данными",
@@ -4546,6 +4650,7 @@ export default {
     staggerTotal: "всего: {{minutes}} мин",
     staggerTotal: "всего: {{minutes}} мин",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     gcodeInjection: "Добавить G-code автозапуска",
     gcodeInjection: "Добавить G-code автозапуска",
+    insufficientBudget: "Недостаточно бюджета",
   },
   },
   backup: {
   backup: {
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",

+ 106 - 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,96 @@ 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: 'Bakiye',
+    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ı
@@ -2298,6 +2389,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',
@@ -4763,6 +4868,7 @@ 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',
   },
   },
 
 
   // Yedekleme
   // Yedekleme

+ 106 - 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,96 @@ 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: "Баланс",
+    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
@@ -2313,6 +2404,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: "Управління даними",
@@ -4828,6 +4933,7 @@ export default {
     staggerTotal: "всього: {{minutes}} хв",
     staggerTotal: "всього: {{minutes}} хв",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     gcodeInjection: "Додати G-код автоматичного друку",
     gcodeInjection: "Додати G-код автоматичного друку",
+    insufficientBudget: "Недостатньо бюджету",
   },
   },
 
 
   // Backup
   // Backup

+ 107 - 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,95 @@ 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: '余额',
+    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
@@ -2313,6 +2404,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: '组名称',
@@ -4773,6 +4879,7 @@ export default {
     staggerTotal: '共 {{minutes}} 分钟',
     staggerTotal: '共 {{minutes}} 分钟',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     gcodeInjection: '注入自动打印G-code',
     gcodeInjection: '注入自动打印G-code',
+    insufficientBudget: '预算不足',
   },
   },
 
 
   // Backup
   // Backup

+ 107 - 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,96 @@ 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: '餘額',
+    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
@@ -2313,6 +2404,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: '群組名稱',
@@ -4773,6 +4879,7 @@ export default {
     staggerTotal: '總計:{{minutes}} 分鐘',
     staggerTotal: '總計:{{minutes}} 分鐘',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     gcodeInjection: '注入自動列印G-code',
     gcodeInjection: '注入自動列印G-code',
+    insufficientBudget: '預算不足',
   },
   },
 
 
   // Backup
   // Backup

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

@@ -0,0 +1,1465 @@
+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 { 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 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 handleDeleteCenter = async (centerId: number, centerName: string) => {
+    const confirmed = window.confirm(
+      t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', { name: centerName })
+    );
+    if (!confirmed) return;
+
+    try {
+      await deleteCostCenterMutation.mutateAsync(centerId);
+      queryClient.invalidateQueries({ queryKey: ['finance'] });
+      if (selectedManageCenterId === centerId) {
+        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');
+    }
+  };
+
+  const handleDeleteTransaction = (transactionId: number) => {
+    const confirmed = window.confirm(
+      t('finance.deleteTransactionConfirm', 'Delete this transaction? Balances will be recalculated automatically.')
+    );
+    if (!confirmed) return;
+
+    deleteTransactionMutation.mutate(transactionId);
+  };
+
+  const 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;
+    }
+    if (amount > 0) 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 '-';
+    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>
+        </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 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', '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={() => handleDeleteCenter(center.id, 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={() => handleDeleteTransaction(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>
+        )}
+      </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>