Przeglądaj źródła

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

maziggy 3 tygodni temu
rodzic
commit
12c6749abe
56 zmienionych plików z 8789 dodań i 23 usunięć
  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,
     send_email,
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 _logger = logging.getLogger(__name__)
 
@@ -480,6 +481,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
                     if user and ldap_user:
                         # Update email and group mappings on each login
                         await _sync_ldap_user(db, user, ldap_user, ldap_config)
+                        # Keep finance defaults idempotently in sync for LDAP users
+                        # (wallet + private cost center + self-membership).
+                        await ensure_user_finance_defaults(db, user)
         except Exception as e:  # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant)
             import logging
 
@@ -1341,6 +1345,8 @@ async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User
         new_user.groups = list(groups_result.scalars().all())
 
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.refresh(new_user)
     logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)

+ 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_requirements import overrides_for_plate
+from backend.app.services.finance_budget import release_budget_reservation, validate_print_budget
 from backend.app.services.notification_service import notification_service
 from backend.app.services.print_batch import (
     BatchDispatchError,
@@ -333,6 +334,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
+        "cost_center_id": item.cost_center_id,
+        "estimated_cost": item.estimated_cost,
         "position": item.position,
         "scheduled_time": item.scheduled_time,
         "require_previous_success": item.require_previous_success,
@@ -942,6 +945,14 @@ async def add_to_queue(
         if not project_result.scalar_one_or_none():
             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
     # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
     # per-slot filament-mapping edit was made), but the archive carries the
@@ -998,6 +1009,8 @@ async def add_to_queue(
             filament_overrides=filament_overrides_json,
             archive_id=data.archive_id,
             library_file_id=data.library_file_id,
+            cost_center_id=data.cost_center_id,
+            estimated_cost=data.estimated_cost,
             scheduled_time=data.scheduled_time,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
@@ -1133,6 +1146,7 @@ async def bulk_update_queue_items(
 
     updated_count = 0
     skipped_count = 0
+    validates_billing_fields = "cost_center_id" in update_data or "estimated_cost" in update_data
 
     for item in items:
         # Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
@@ -1147,6 +1161,15 @@ async def bulk_update_queue_items(
             skipped_count += 1
             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():
             setattr(item, field, value)
         updated_count += 1
@@ -1552,6 +1575,12 @@ async def cancel_batch(
     cancelled_count = 0
     for item in pending_items:
         item.status = "cancelled"
+        await release_budget_reservation(
+            db,
+            source_type="print_queue",
+            source_id=item.id,
+            status="released",
+        )
         cancelled_count += 1
 
     batch.status = "cancelled"
@@ -1797,6 +1826,14 @@ async def update_queue_item(
             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
     # validations ran since the guard above, and a scheduler worker may have
     # claimed the row in that gap. A fresh read (item isn't dirty yet, so no
@@ -1844,6 +1881,12 @@ async def delete_queue_item(
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
+    await release_budget_reservation(
+        db,
+        source_type="print_queue",
+        source_id=item.id,
+        status="released",
+    )
     await db.delete(item)
     await db.commit()
 
@@ -1956,6 +1999,12 @@ async def cancel_queue_item(
 
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
+    await release_budget_reservation(
+        db,
+        source_type="print_queue",
+        source_id=item.id,
+        status="released",
+    )
     await db.commit()
 
     logger.info("Cancelled queue item %s", item_id)
@@ -2120,6 +2169,14 @@ async def start_queue_item(
     if item.status != "pending":
         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
     # spool swap between scheduler flagging and the user clicking ▶ clears
     # the block automatically.

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

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

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

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

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

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

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

@@ -261,6 +261,7 @@ async def init_db():
         external_link,
         filament,
         filament_sku_settings,
+        finance,
         github_backup,
         group,
         kprofile_note,
@@ -1024,6 +1025,213 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
         raise
 
 
+async def _migrate_create_finance_tables(conn) -> None:
+    """Create finance tables missing from databases that predate billing.
+
+    ``Base.metadata.create_all()`` covers fresh installs, but upgrade and
+    restore paths can run the handwritten migrations against an existing
+    PostgreSQL schema.  The finance column migrations below must therefore not
+    assume these tables already exist.
+
+    ``UserWallet`` is mapped to ``user_wallets``.  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):
     """Run all schema migrations and data backfills on startup.
 
@@ -1038,6 +1246,11 @@ async def run_migrations(conn):
     """
     from sqlalchemy import text
 
+    # Existing PostgreSQL databases predate the finance ORM tables. These must
+    # exist before any ALTER TABLE / CREATE INDEX statements below reference
+    # them. Fresh installs remain idempotent because create_all() runs first.
+    await _migrate_create_finance_tables(conn)
+
     # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
     # Links a retry-failed run back to its parent so the dashboard can show
     # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
@@ -1058,6 +1271,12 @@ async def run_migrations(conn):
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
+    # Migration: Add wallet_charge_skipped column to print_archives so deleted print charges stay deleted
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT FALSE")
+
     # Migration: Add content_hash column to print_archives for duplicate detection
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")
 
@@ -1092,6 +1311,35 @@ async def run_migrations(conn):
     # Migration: Add is_deleted column to maintenance_types for soft-deletes
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
 
+    # Migration: Add cost_center columns expected by current finance model
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT FALSE")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN total_budget 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
     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_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
     try:
         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)",
     )
 
+    # Migration: Add unique indexes to prevent duplicate print-charge transactions
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_print_run ON wallet_transactions (transaction_type, print_run_id)",
+    )
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive ON wallet_transactions (transaction_type, print_archive_id)",
+    )
+
     # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     if is_sqlite():
@@ -1308,6 +1574,17 @@ async def run_migrations(conn):
     # Migration: Add manual_start column to print_queue for staged prints
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")
 
+    # Migration: Add cost_center_id column to print_queue for billing metadata
+    try:
+        async with conn.begin_nested():
+            await conn.execute(
+                text(
+                    "ALTER TABLE print_queue ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL"
+                )
+            )
+    except (OperationalError, ProgrammingError):
+        pass  # Already applied
+
     # Migration: Add wiki_url column to maintenance_types for documentation links
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
 
@@ -1420,12 +1697,15 @@ async def run_migrations(conn):
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
+                cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
+                col_names = {col[1] for col in cols_result.fetchall()}
                 await conn.execute(
                     text("""
                     CREATE TABLE print_queue_new (
                         id INTEGER PRIMARY KEY,
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+                        cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
@@ -1441,15 +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
-                    SELECT id, printer_id, archive_id, project_id, position, scheduled_time,
+                    SELECT id, printer_id, archive_id, cost_center_id, project_id, position, scheduled_time,
                            manual_start, require_previous_success, auto_off_after, ams_mapping,
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                 """)
-                )
+                    )
+                else:
+                    await conn.execute(
+                        text("""
+                    INSERT INTO print_queue_new
+                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                           manual_start, require_previous_success, auto_off_after, ams_mapping,
+                           status, started_at, completed_at, error_message, created_at
+                    FROM print_queue
+                """)
+                    )
                 await conn.execute(text("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
@@ -1653,6 +1944,8 @@ async def run_migrations(conn):
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
+                cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
+                col_names = {col[1] for col in cols_result.fetchall()}
                 await conn.execute(
                     text("""
                     CREATE TABLE print_queue_new2 (
@@ -1660,6 +1953,7 @@ async def run_migrations(conn):
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
                         library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
+                        cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
@@ -1682,17 +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
-                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                    SELECT id, printer_id, archive_id, NULL, cost_center_id, project_id, position, scheduled_time,
                            manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
                            COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
                            COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                 """)
-                )
+                    )
+                else:
+                    await conn.execute(
+                        text("""
+                    INSERT INTO print_queue_new2
+                    SELECT id, printer_id, archive_id, NULL, NULL, project_id, position, scheduled_time,
+                           manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
+                           COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
+                           COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
+                           status, started_at, completed_at, error_message, created_at
+                    FROM print_queue
+                """)
+                    )
                 await conn.execute(text("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
@@ -2649,6 +2956,61 @@ async def run_migrations(conn):
     # Migration: Auto-print G-code injection (#422)
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
 
+    # Migration: Store estimated print cost for budget checks before queued jobs start
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
+
+    # 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
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
@@ -4231,6 +4593,18 @@ async def seed_default_groups():
         "library:read": "library:read_own",
     }
 
+    FINANCE_PERMISSION_MIGRATION = {
+        "finance:read_own": "cost_centers:read_own",
+        "finance:read_all": "cost_centers:read_all",
+        "finance:transactions:create": "cost_centers:modify",
+        "finance:create_transactions": "cost_centers:modify",
+        "finance:createTransactions:create": "cost_centers:modify",
+        "finance:cost_centers:create": "cost_centers:create",
+        "finance:cost_centers:update": "cost_centers:modify",
+        "finance:cost_centers:assign_users": "cost_centers:modify",
+        "finance:budgets:update": "cost_centers:modify",
+    }
+
     async with async_session() as session:
         # Get existing groups
         result = await session.execute(select(Group))
@@ -4271,6 +4645,16 @@ async def seed_default_groups():
                                 "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
                             )
 
+                    for old_perm, new_perm in FINANCE_PERMISSION_MIGRATION.items():
+                        if old_perm in new_permissions:
+                            new_permissions.remove(old_perm)
+                            if new_perm not in new_permissions:
+                                new_permissions.append(new_perm)
+                            updated = True
+                            logger.info(
+                                "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
+                            )
+
                     # For Administrators, also ensure they get *_all permissions if they have any new *_own
                     if group_name == "Administrators":
                         for _own_perm, all_perm in [
@@ -4522,3 +4906,89 @@ async def seed_color_catalog():
             )
         await session.commit()
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
+
+
+async def repair_wallet_ledger():
+    """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_FILTER_BY_USER = "stats:filter_by_user"
 
+    # Cost Centers
+    COST_CENTERS_READ_OWN = "cost_centers:read_own"
+    COST_CENTERS_READ_ALL = "cost_centers:read_all"
+    COST_CENTERS_MODIFY = "cost_centers:modify"
+    COST_CENTERS_CREATE = "cost_centers:create"
+
     # System Info
     SYSTEM_READ = "system:read"
 
@@ -305,6 +311,12 @@ PERMISSION_CATEGORIES = {
         Permission.STATS_READ,
         Permission.STATS_FILTER_BY_USER,
     ],
+    "Finance": [
+        Permission.COST_CENTERS_READ_OWN,
+        Permission.COST_CENTERS_READ_ALL,
+        Permission.COST_CENTERS_MODIFY,
+        Permission.COST_CENTERS_CREATE,
+    ],
     "System": [
         Permission.SYSTEM_READ,
     ],
@@ -460,6 +472,8 @@ DEFAULT_GROUPS = {
             Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
+            # Finance - own visibility
+            Permission.COST_CENTERS_READ_OWN.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
             # Slicer Pipelines - full access

+ 166 - 1
backend/app/main.py

@@ -30,6 +30,7 @@ from backend.app.api.routes import (
     discovery,
     external_links,
     filaments,
+    finance,
     firmware,
     github_backup,
     groups,
@@ -404,6 +405,9 @@ _expected_prints: dict[tuple[int, str], int] = {}
 # Used by usage tracker to map 3MF slots to physical AMS trays
 _print_ams_mappings: dict[int, list[int]] = {}
 
+# Track cost center selection for the current print run: {archive_id: cost_center_id}
+_print_cost_center_ids: dict[int, int] = {}
+
 # Track plate_id for prints from multi-plate 3MFs: {archive_id: plate_id}
 # Used by usage tracker to scope 3MF parsing to the dispatched plate (#1697).
 # Populated by direct-Print and queue dispatch paths; queue prints also have a
@@ -418,6 +422,9 @@ _last_progress_milestone: dict[int, int] = {}
 # Track whether first layer complete notification has been sent for current print
 _first_layer_notified: dict[int, bool] = {}
 
+# Track whether we already sent a kill-switch stop for the current unauthorized print
+_unauthorized_print_kill_sent: set[int] = set()
+
 # Track HMS errors that have been notified: {printer_id: set of error codes}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
@@ -643,6 +650,54 @@ _expected_print_registered_at: dict[tuple[int, str], float] = {}
 _EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60  # 15 minutes
 _expected_prints_cleanup_task: asyncio.Task | None = None
 
+_ACTIVE_PRINT_STATES: set[str] = {"RUNNING", "PRINTING", "PAUSE"}
+
+
+def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple[int, str]]:
+    """Build filename keys for matching a printer status update to Bambuddy-owned jobs."""
+
+    possible_keys: list[tuple[int, str]] = []
+    filename = (state.gcode_file or state.current_print or "").strip()
+    subtask_name = (state.subtask_name or "").strip()
+
+    if subtask_name:
+        possible_keys.append((printer_id, subtask_name))
+        possible_keys.append((printer_id, f"{subtask_name}.3mf"))
+        possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
+
+    if filename:
+        base_name = filename.rsplit("/", 1)[-1]
+        if base_name.endswith(".gcode.3mf"):
+            root_name = base_name[: -len(".gcode.3mf")]
+            possible_keys.append((printer_id, root_name))
+            possible_keys.append((printer_id, base_name))
+            possible_keys.append((printer_id, f"{root_name}.gcode"))
+            possible_keys.append((printer_id, f"{root_name}.3mf"))
+        elif base_name.endswith(".3mf"):
+            root_name = base_name[: -len(".3mf")]
+            possible_keys.append((printer_id, root_name))
+            possible_keys.append((printer_id, base_name))
+        elif base_name.endswith(".gcode"):
+            root_name = base_name[: -len(".gcode")]
+            possible_keys.append((printer_id, root_name))
+            possible_keys.append((printer_id, f"{root_name}.3mf"))
+            possible_keys.append((printer_id, base_name))
+        else:
+            possible_keys.append((printer_id, base_name))
+            possible_keys.append((printer_id, f"{base_name}.3mf"))
+
+    return possible_keys
+
+
+def _is_bambuddy_authorized_print(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:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
@@ -715,6 +770,7 @@ def register_expected_print(
     archive_id: int,
     ams_mapping: list[int] | None = None,
     created_by_id: int | None = None,
+    cost_center_id: int | None = None,
     plate_id: int | None = None,
 ):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
@@ -728,6 +784,8 @@ def register_expected_print(
     # Store AMS mapping for usage tracking at print completion
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
+    if cost_center_id is not None:
+        _print_cost_center_ids[archive_id] = cost_center_id
     # Store plate_id for usage tracking when this is a single-plate dispatch from
     # a multi-plate 3MF — without this, the direct-Print path attributes the whole
     # file's filament total to the spool instead of just the printed plate (#1697).
@@ -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}"
     )
 
+    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)
     try:
         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:
             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
     # is recorded even when auto-archive is disabled) ---
     usage_results: list[dict] = []
@@ -5414,6 +5533,50 @@ async def on_print_complete(printer_id: int, data: dict):
 
     log_timing("Archive status update")
 
+    # Apply finance wallet charge or release reservations once. For all partial
+    # terminal states (failed, aborted at the printer display, or cancelled via
+    # Bambuddy) use this run's measured spool delta, falling back to the last
+    # valid printer progress. PrintArchive.filament_used_grams is the slicer
+    # estimate and therefore cannot represent an interrupted run.
+    try:
+        if data.get("status") in ("completed", "failed", "aborted", "cancelled"):
+            async with async_session() as db:
+                from backend.app.models.archive import PrintArchive
+                from backend.app.services.finance_billing import apply_print_charge_for_archive
+
+                archive = await db.get(PrintArchive, archive_id)
+                if archive and 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)
     try:
         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_status,
                     _est_grams,
-                    data.get("progress"),
+                    data.get("last_progress", data.get("progress")),
                     usage_results,
                 )
 
@@ -6894,6 +7057,7 @@ def _evict_stale_expected_prints() -> None:
     for archive_id in evicted_archive_ids:
         if archive_id not in live_archive_ids:
             _print_ams_mappings.pop(archive_id, None)
+            _print_cost_center_ids.pop(archive_id, None)
             _print_plate_ids.pop(archive_id, None)
 
     logging.getLogger(__name__).info(
@@ -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(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
+app.include_router(finance.router, prefix=app_settings.api_prefix)
 app.include_router(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(labels.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)

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

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

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

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

@@ -0,0 +1,118 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class WalletBalanceResponse(BaseModel):
+    user_id: int
+    balance: float
+    currency: str
+    updated_at: datetime | None = None
+
+
+class WalletTransactionResponse(BaseModel):
+    id: int
+    user_id: int
+    cost_center_id: int | None = None
+    transaction_type: Literal["print_charge", "deposit", "withdraw", "manual_adjustment"]
+    amount: float
+    balance_after: float | None = None
+    description: str | None = None
+    created_by_user_id: int | None = None
+    print_run_id: str | None = None
+    print_archive_id: int | None = None
+    print_queue_id: int | None = None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class WalletTransactionListResponse(BaseModel):
+    items: list[WalletTransactionResponse]
+    total: int
+    limit: int
+    offset: int
+
+
+class CostCenterSummaryResponse(BaseModel):
+    id: int
+    name: str
+    is_private: bool
+    owner_user_id: int | None = None
+    is_active: bool
+    total_balance: float = 0.0
+    total_budget: float | None = None
+    monthly_budget: float | None = None
+    budget_mode: str = "none"
+    budget_limit: float | None = None
+    budget_used: float | None = None
+    budget_available: float | None = None
+    can_print: bool = True
+
+    class Config:
+        from_attributes = True
+
+
+class WalletAdjustmentRequest(BaseModel):
+    amount: float = Field(..., gt=0)
+    description: str | None = None
+    cost_center_id: int | None = None
+
+
+class WalletAdjustmentResponse(BaseModel):
+    transaction: WalletTransactionResponse
+    balance: WalletBalanceResponse
+
+
+class TransactionEditRequest(BaseModel):
+    user_id: int | None = None
+    cost_center_id: int | None = None
+    amount: float | None = None
+    description: str | None = None
+
+
+class ManualPrintRequest(BaseModel):
+    user_id: int
+    cost_center_id: int
+    amount: float
+    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
     # Project to associate the resulting archive with
     project_id: int | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
@@ -149,6 +151,8 @@ class PrintQueueItemUpdate(BaseModel):
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
     # physical nozzle position IDs from BambuStudio's project_file MQTT
     # body; sent back to the printer verbatim on dispatch.
@@ -174,6 +178,8 @@ class PrintQueueItemResponse(BaseModel):
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     position: int
     scheduled_time: UTCDatetime
     require_previous_success: bool
@@ -321,6 +327,8 @@ class PrintQueueBulkUpdate(BaseModel):
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
 
 
 class PrintQueueBulkUpdateResponse(BaseModel):

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

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

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

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

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

@@ -3189,10 +3189,11 @@ class BambuMQTTClient:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
             # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
-            if self.state.progress > 0:
-                self._last_valid_progress = self.state.progress
             previous_progress = self.state.progress
-            self.state.progress = float(data["mc_percent"])
+            new_progress = float(data["mc_percent"])
+            if new_progress > 0:
+                self._last_valid_progress = new_progress
+            self.state.progress = new_progress
             # #2547: strictly-increasing only. The firmware resets progress to 0
             # on cancel and re-reports the same percent on most frames; neither
             # is the print advancing, and both would make the frame bank grab a

+ 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 pathlib import Path
 
+from fastapi import HTTPException
 from sqlalchemy import func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -35,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.filament_deficit import compute_deficit_for_queue_item
+from backend.app.services.finance_budget import (
+    create_budget_reservation,
+    release_budget_reservation,
+    validate_print_budget,
+)
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -504,6 +510,11 @@ class PrintScheduler:
         # sequential caller, callbacks on the same loop, so no lock.
         # item_id -> (printer_id, remote_filename, archive_id)
         self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
+        # Budget reservations created for a dispatch whose print command has
+        # not been confirmed yet. `_dispatch_one` releases these on every
+        # unsuccessful exit; a successful start removes the item id and leaves
+        # the reservation for finance_billing to consume with the archive.
+        self._unconfirmed_budget_reservations: set[int] = set()
 
     async def run(self):
         """Main loop - check queue every interval."""
@@ -1281,6 +1292,11 @@ class PrintScheduler:
                 # A confirmed send removes the entry itself, so this is a no-op
                 # on the happy path.
                 self._rollback_unconfirmed_expected_print(item_id)
+                # Mirror the pre-#1625 background-dispatch lifecycle: a
+                # reservation survives only after start_print() accepted the
+                # command. Failure, cancellation, deferral, and exceptions all
+                # release it here.
+                await asyncio.shield(self._release_unconfirmed_budget_reservation(item_db, item_id))
                 # Release the claim on every exit. Once dispatch has finished the
                 # row's status carries the lock (printing/failed/cancelled are all
                 # != pending), so the token is only needed for the duration of the
@@ -1312,6 +1328,38 @@ class PrintScheduler:
                 exc_info=True,
             )
 
+    async def _release_unconfirmed_budget_reservation(self, db: AsyncSession, item_id: int) -> None:
+        """Release a queue reservation when dispatch ended before MQTT send."""
+        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:
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
 
@@ -3628,6 +3676,43 @@ class PrintScheduler:
         """
         logger.info("Starting queue item %s", item.id)
 
+        # Also covers a reservation left active by a process interruption
+        # during an earlier attempt. `_dispatch_one` releases this marker on
+        # every exit unless start_print() confirms that the command was sent.
+        self._unconfirmed_budget_reservations.add(item.id)
+        try:
+            from backend.app.models.user import User
+
+            queue_user = await db.get(User, item.created_by_id) if item.created_by_id is not None else None
+            await validate_print_budget(
+                db,
+                cost_center_id=item.cost_center_id,
+                estimated_cost=item.estimated_cost,
+                current_user=queue_user,
+                exclude_queue_item_id=item.id,
+                exclude_reservation_source_type="print_queue",
+                exclude_reservation_source_id=item.id,
+            )
+            budget_reservation = await create_budget_reservation(
+                db,
+                cost_center_id=item.cost_center_id,
+                estimated_cost=item.estimated_cost,
+                current_user=queue_user,
+                source_type="print_queue",
+                source_id=item.id,
+                print_archive_id=item.archive_id,
+                exclude_queue_item_id=item.id,
+            )
+            await db.commit()
+        except HTTPException as exc:
+            item.status = "failed"
+            item.error_message = str(exc.detail)
+            item.completed_at = datetime.now(timezone.utc)
+            await db.commit()
+            logger.error("Queue item %s: Budget check failed: %s", item.id, item.error_message)
+            await self._power_off_if_needed(db, item)
+            return
+
         # Get printer first (needed for both paths)
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         printer = result.scalar_one_or_none()
@@ -3748,11 +3833,14 @@ class PrintScheduler:
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     project_id=item.project_id,
+                    cost_center_id=item.cost_center_id,
                     library_file_id=item.library_file_id,  # per-file project progress (#1897)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 if archive:
                     item.archive_id = archive.id
+                    if budget_reservation is not None:
+                        budget_reservation.print_archive_id = archive.id
                     if item.cleanup_library_after_dispatch and not library_file.is_external:
                         item.library_file_id = None
                         cleanup_disk_paths.append(file_path)
@@ -4068,6 +4156,7 @@ class PrintScheduler:
                 archive.id,
                 ams_mapping=ams_mapping,
                 created_by_id=item.created_by_id,
+                cost_center_id=item.cost_center_id,
                 plate_id=item.plate_id,
             )
             # Registration happens before the print command by necessity (the
@@ -4204,6 +4293,7 @@ class PrintScheduler:
             # survive. Anything still in this dict when _dispatch_one exits gets
             # rolled back.
             self._unconfirmed_expected_print.pop(item.id, None)
+            self._unconfirmed_budget_reservations.discard(item.id)
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # status='processing' from upload start until the printer acked
@@ -4575,6 +4665,12 @@ class PrintScheduler:
                         f"prompt or error, confirm its SD card is readable, and start the job again."
                     )
                 item.completed_at = datetime.now(timezone.utc)
+                await release_budget_reservation(
+                    db,
+                    source_type="print_queue",
+                    source_id=item.id,
+                    status="released",
+                )
                 await db.commit()
                 return "gave_up"
             item.status = "pending"

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

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

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

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

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

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

@@ -2,6 +2,19 @@
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.finance import CostCenter
+from backend.app.models.settings import Settings
+
+
+async def enable_billing(db_session):
+    setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
+    if setting is None:
+        db_session.add(Settings(key="billing_enabled", value="true"))
+    else:
+        setting.value = "true"
+    await db_session.commit()
 
 
 class TestPrintQueueAPI:
@@ -125,6 +138,149 @@ class TestPrintQueueAPI:
         assert result["status"] == "pending"
         assert result["manual_start"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_cost_center_id(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added to queue with cost_center_id."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory()
+        cost_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.integration
     async def test_add_to_queue_with_manual_start(
@@ -181,6 +337,30 @@ class TestPrintQueueAPI:
         result = response.json()
         assert result["skip_filament_check"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_cost_center_id(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Verify a pending queue item can be updated with cost_center_id."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory()
+        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.integration
     async def test_add_to_queue_with_project_id(
@@ -634,6 +814,54 @@ class TestPrintQueueAPI:
         assert response.status_code == 200
         assert response.json()["message"] == "Queue item deleted"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_queue_item_releases_reserved_budget(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Deleting a pending cost-center queue item releases its reserved budget."""
+        await enable_billing(db_session)
+        printer = await printer_factory()
+        archive = await archive_factory()
+        cost_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.integration
     async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
@@ -760,6 +988,24 @@ class TestQueueStartEndpoint:
         result = response.json()
         assert result["manual_start"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_queue_item_with_cost_center_requires_estimated_cost(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Starting a cost-center queue item requires a stored estimate."""
+        await enable_billing(db_session)
+        cost_center = CostCenter(name="Start Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
+        db_session.add(cost_center)
+        await db_session.commit()
+        await db_session.refresh(cost_center)
+        item = await queue_item_factory(manual_start=True, cost_center_id=cost_center.id, estimated_cost=None)
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+
+        assert response.status_code == 400
+        assert "Estimated cost is required" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_start_queue_item_not_found(self, async_client: AsyncClient):
@@ -1460,6 +1706,57 @@ class TestBulkUpdateEndpoint:
         assert response.status_code == 400
         assert "printer not found" in response.json()["detail"].lower()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_cost_center_requires_estimated_cost(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Bulk assigning a cost center requires an estimate, same as single-item updates."""
+        await enable_billing(db_session)
+        item = await queue_item_factory()
+        cost_center = CostCenter(name="Bulk Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
+        db_session.add(cost_center)
+        await db_session.commit()
+        await db_session.refresh(cost_center)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id], "cost_center_id": cost_center.id},
+        )
+
+        assert response.status_code == 400
+        assert "Estimated cost is required" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_cost_center_counts_pending_reservations(
+        self, async_client: AsyncClient, queue_item_factory, 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:
     """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,
         created_by_id=None,
         project_id=None,
+        cost_center_id=None,
         plate_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 { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
+import { FinancePage } from './pages/FinancePage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
@@ -211,6 +212,7 @@ function App() {
                   <Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
+                  <Route path="finance" element={<PermissionRoute permission="cost_centers:read_own"><FinancePage /></PermissionRoute>} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />

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

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

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

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

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

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

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

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

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

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

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profile',
     maintenance: 'Wartung',
     projects: 'Projekte',
+    finance: 'Finanzen',
     inventory: 'Filament',
     files: 'Dateimanager',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: 'Aktionen',
     status: 'Status',
     name: 'Name',
+    user: 'Benutzer',
     description: 'Beschreibung',
     date: 'Datum',
     time: 'Zeit',
@@ -124,6 +126,95 @@ export default {
     duplicate: 'Duplizieren',
     left: 'Links',
     right: 'Rechts',
+    run: 'Ausführen',
+    running: 'Wird ausgeführt...',
+  },
+
+  finance: {
+    allTypes: 'Alle Typen',
+    allCostCenters: 'Alle Kostenstellen',
+    title: 'Finanzen',
+    subtitle: 'Wallet, persönliche Transaktionen und Kostenstellen',
+    noAccess: 'Du hast keine Berechtigung, Finanzdaten anzuzeigen.',
+    personalView: 'Persönliche Ansicht',
+    adminView: 'Admin-Ansicht',
+    createCostCenter: 'Kostenstelle erstellen',
+    adjustWallet: 'Wallet anpassen',
+    addManualPrint: 'Manuellen Druck nachtragen',
+    manageMembers: 'Mitglieder der Kostenstelle verwalten',
+    currentBalance: 'Persönlicher Kontostand',
+    transactions: 'Transaktionen',
+    personalTransactions: 'Persönliche Transaktionen',
+    costCenters: 'Kostenstellen',
+    availableForPrinting: 'Für Druckzuordnung verfügbar',
+    costCenterName: 'Name',
+    budgetType: 'Budgettyp',
+    monthlyBudget: 'Monatsbudget',
+    totalBudget: 'Gesamtbudget',
+    noBudget: "kein Budget gesetzt",
+    create: 'Erstellen',
+    selectUser: 'Benutzer auswählen',
+    transactionType: 'Typ',
+    amount: 'Betrag',
+    noCostCenter: 'Keine Kostenstelle',
+    descriptionOptional: 'Beschreibung (optional)',
+    applyAdjustment: 'Anpassung anwenden',
+    memberCanPrint: 'Mitglied darf drucken',
+    addMember: 'Mitglied hinzufügen',
+    canPrint: 'Darf drucken',
+    noMembers: 'Keine Mitglieder zugewiesen.',
+    editCostCenter: 'Kostenstelle bearbeiten',
+    myCostCenters: 'Meine Kostenstellen',
+    costCentersHint: 'Budgetgrenzen prüfen und Kosten im Blick behalten',
+    noCostCenters: 'Keine Kostenstellen gefunden.',
+    owner: 'Besitzer',
+    balance: '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
@@ -2294,6 +2385,21 @@ export default {
     },
     externalCameras: 'Externe Kameras',
     costTracking: 'Kostenverfolgung',
+    billingEnabled: 'Abrechnung aktivieren',
+    billingEnabledDescription: 'Kostenstellen aktivieren und Drucke verbuchen',
+    printerKillSwitch: 'Automatischer Druckstopp',
+    printerKillSwitchDescription: 'Drucke ohne Authorisierung sofort abbrechen',
+    financeBudgetReset: 'Finanzbudget monatlich zurücksetzen',
+    financeBudgetResetDay: 'Reset-Tag',
+    financeBudgetResetDayHelp: 'Für kurze Monate verwendet der Reset den letzten Tag des Monats.',
+    financeBudgetResetTimezone: 'Reset-Zeitzone',
+    financeBudgetResetTimezoneHelp: 'Der Budget-Fenster-Start wird in dieser Zeitzone berechnet.',
+    rebuildLedger: 'Ledger wiederaufbauen',
+    rebuildLedgerStarted: 'Ledger-Wiederaufbau gestartet',
+    rebuildLedgerConfirmTitle: 'Ledger wiederaufbauen?',
+    rebuildLedgerConfirmMessage:
+      'Dies baut das Wallet-Ledger neu auf, um historische Saldenwerte zu reparieren. Führen Sie dies nur aus, wenn Sie genau wissen, was Sie tun.',
+    rebuildLedgerInProgress: 'Wiederaufbau wird gestartet...',
     printsOnly: 'Nur Drucke',
     totalConsumption: 'Gesamtverbrauch',
     dataManagement: 'Datenverwaltung',
@@ -3984,7 +4090,7 @@ export default {
       title: 'Kostenverfolgung',
       filamentCost: 'Filamentkosten',
       energy: 'Energie',
-      totalCost: 'Gesamtkosten',
+      totalCost: 'Erwartete Gesamtkosten',
       total: 'Gesamt',
       includesBom: 'inkl. Stückliste',
       budget: 'Budget',
@@ -4785,6 +4891,7 @@ export default {
     staggerTotal: 'insgesamt: {{minutes}} Min.',
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     gcodeInjection: 'Auto-Print G-code einfügen',
+    insufficientBudget: 'Budget nicht ausreichend',
   },
 
   // Backup

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

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     projects: 'Projects',
+    finance: 'Finance',
     inventory: 'Filament',
     files: 'File Manager',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: 'Actions',
     status: 'Status',
     name: 'Name',
+    user: 'User',
     description: 'Description',
     date: 'Date',
     time: 'Time',
@@ -124,6 +126,95 @@ export default {
     duplicate: 'Duplicate',
     left: 'Left',
     right: 'Right',
+    run: 'Run',
+    running: 'Running...',
+  },
+
+  finance: {
+    allTypes: 'All types',
+    allCostCenters: 'All cost centers',
+    title: 'Finance',
+    subtitle: 'Wallet, personal transactions, and cost centers',
+    noAccess: 'You do not have permission to view finance data.',
+    personalView: 'Personal view',
+    adminView: 'Admin view',
+    createCostCenter: 'Create cost center',
+    adjustWallet: 'Adjust wallet',
+    addManualPrint: 'Add manual print',
+    manageMembers: 'Manage cost center members',
+    currentBalance: 'Personal balance',
+    transactions: 'Transactions',
+    personalTransactions: 'Personal transactions',
+    costCenters: 'Cost centers',
+    availableForPrinting: 'Available for print assignment',
+    costCenterName: 'Name',
+    budgetType: 'Budget type',
+    monthlyBudget: 'Monthly budget',
+    totalBudget: 'Total budget',
+    noBudget: "No budget",
+    create: 'Create',
+    selectUser: 'Select user',
+    transactionType: 'Type',
+    amount: 'Amount',
+    noCostCenter: 'No cost center',
+    descriptionOptional: 'Description (optional)',
+    applyAdjustment: 'Apply adjustment',
+    memberCanPrint: 'Member can print',
+    addMember: 'Add member',
+    canPrint: 'Can print',
+    noMembers: 'No members assigned.',
+    editCostCenter: 'Edit cost center',
+    myCostCenters: 'My cost centers',
+    costCentersHint: 'Review budget limits and keep costs under control',
+    noCostCenters: 'No cost centers found.',
+    owner: 'Owner',
+    balance: '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
@@ -2313,6 +2404,21 @@ export default {
     },
     externalCameras: 'External Cameras',
     costTracking: 'Cost Tracking',
+    billingEnabled: 'Enable Billing',
+    billingEnabledDescription: 'Charge users for prints and enable finance features',
+    printerKillSwitch: 'Unauthorized print kill switch',
+    printerKillSwitchDescription: 'Immediately stop prints that start without authorization.',
+    financeBudgetReset: 'Finance Monthly Budget Reset',
+    financeBudgetResetDay: 'Reset Day',
+    financeBudgetResetDayHelp: 'For short months, reset uses the last day of the month.',
+    financeBudgetResetTimezone: 'Reset timezone',
+    financeBudgetResetTimezoneHelp: 'Budget window start is calculated in this timezone.")',
+    rebuildLedger: 'Rebuild wallet ledger',
+    rebuildLedgerStarted: 'Ledger rebuild started',
+    rebuildLedgerConfirmTitle: 'Rebuild wallet ledger?',
+    rebuildLedgerConfirmMessage:
+      'This will rebuild the wallet ledger to repair historical balance values. Run this only if you know what you are doing.',
+    rebuildLedgerInProgress: 'Starting rebuild...',
     printsOnly: 'Prints Only',
     totalConsumption: 'Total Consumption',
     dataManagement: 'Data Management',
@@ -4013,7 +4119,7 @@ export default {
       title: 'Cost Tracking',
       filamentCost: 'Filament Cost',
       energy: 'Energy',
-      totalCost: 'Total Cost',
+      totalCost: 'Estimated Cost',
       total: 'Total',
       includesBom: 'incl. BOM',
       budget: 'Budget',
@@ -4828,6 +4934,7 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Stagger to {{count}} printers',
     gcodeInjection: 'Inject auto-print G-code',
+    insufficientBudget: 'Insufficient Budget',
   },
 
   // Backup

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Mantenimiento',
     projects: 'Proyectos',
     inventory: 'Filamento',
+    finance: 'Finanzas',
     files: 'Gestor de archivos',
     makerworld: 'MakerWorld',
     notifications: 'Notificaciones',
@@ -124,6 +125,96 @@ export default {
     duplicate: 'Duplicar',
     left: 'Izquierda',
     right: 'Derecha',
+    user: 'Usuario',
+    run: 'Ejecutar',
+    running: 'Ejecutando...',
+  },
+
+  finance: {
+    allTypes: 'Todos los tipos',
+    allCostCenters: 'Todos los centros de costes',
+    title: 'Finanzas',
+    subtitle: 'Monedero, transacciones personales y centros de costes',
+    noAccess: 'No tienes permiso para ver los datos financieros.',
+    personalView: 'Vista personal',
+    adminView: 'Vista de administración',
+    createCostCenter: 'Crear centro de costes',
+    adjustWallet: 'Ajustar monedero',
+    addManualPrint: 'Añadir impresión manual',
+    manageMembers: 'Gestionar miembros del centro de costes',
+    currentBalance: 'Saldo personal',
+    transactions: 'Transacciones',
+    personalTransactions: 'Transacciones personales',
+    costCenters: 'Centros de costes',
+    availableForPrinting: 'Disponible para asignar impresiones',
+    costCenterName: 'Nombre',
+    budgetType: 'Tipo de presupuesto',
+    monthlyBudget: 'Presupuesto mensual',
+    totalBudget: 'Presupuesto total',
+    noBudget: 'Sin presupuesto',
+    create: 'Crear',
+    selectUser: 'Seleccionar usuario',
+    transactionType: 'Tipo',
+    amount: 'Importe',
+    noCostCenter: 'Sin centro de costes',
+    descriptionOptional: 'Descripción (opcional)',
+    applyAdjustment: 'Aplicar ajuste',
+    memberCanPrint: 'El miembro puede imprimir',
+    addMember: 'Añadir miembro',
+    canPrint: 'Puede imprimir',
+    noMembers: 'No hay miembros asignados.',
+    editCostCenter: 'Editar centro de costes',
+    myCostCenters: 'Mis centros de costes',
+    costCentersHint: 'Revisa los límites presupuestarios y mantén los costes bajo control',
+    noCostCenters: 'No se encontraron centros de costes.',
+    owner: 'Propietario',
+    balance: 'Saldo',
+    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
@@ -2297,6 +2388,20 @@ export default {
     },
     externalCameras: 'Cámaras externas',
     costTracking: 'Seguimiento de costes',
+    billingEnabled: 'Activar facturación',
+    billingEnabledDescription: 'Cobrar las impresiones a los usuarios y activar las funciones financieras',
+    printerKillSwitch: 'Parada de impresiones no autorizadas',
+    printerKillSwitchDescription: 'Detiene inmediatamente las impresiones que comiencen sin autorización.',
+    financeBudgetReset: 'Reinicio mensual del presupuesto financiero',
+    financeBudgetResetDay: 'Día de reinicio',
+    financeBudgetResetDayHelp: 'En los meses cortos, se usa el último día del mes.',
+    financeBudgetResetTimezone: 'Zona horaria del reinicio',
+    financeBudgetResetTimezoneHelp: 'El inicio del período presupuestario se calcula en esta zona horaria.',
+    rebuildLedger: 'Reconstruir libro mayor del monedero',
+    rebuildLedgerStarted: 'Se inició la reconstrucción del libro mayor',
+    rebuildLedgerConfirmTitle: '¿Reconstruir el libro mayor del monedero?',
+    rebuildLedgerConfirmMessage: 'Esto reconstruirá el libro mayor para reparar saldos históricos. Ejecútalo solo si sabes lo que estás haciendo.',
+    rebuildLedgerInProgress: 'Iniciando reconstrucción...',
     printsOnly: 'Solo impresiones',
     totalConsumption: 'Consumo total',
     dataManagement: 'Gestión de datos',
@@ -4793,6 +4898,7 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Escalonar en {{count}} impresoras',
     gcodeInjection: 'Inyectar G-code de impresión automática',
+    insufficientBudget: 'Presupuesto insuficiente',
   },
 
   // Backup

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Maintenance',
     projects: 'Projets',
     inventory: 'Filament',
+    finance: 'Finances',
     files: 'Gestionnaire de fichiers',
     makerworld: 'MakerWorld',
     notifications: 'Notifications',
@@ -124,6 +125,96 @@ export default {
     duplicate: 'Dupliquer',
     left: 'Gauche',
     right: 'Droite',
+    user: 'Utilisateur',
+    run: 'Exécuter',
+    running: 'En cours d\'exécution...',
+  },
+
+  finance: {
+    allTypes: 'Tous les types',
+    allCostCenters: 'Tous les centres de coûts',
+    title: 'Finances',
+    subtitle: 'Portefeuille, transactions personnelles et centres de coûts',
+    noAccess: 'Vous n\'avez pas la permission d\'accéder aux données financières.',
+    personalView: 'Vue personnelle',
+    adminView: 'Vue administrateur',
+    createCostCenter: 'Créer un centre de coûts',
+    adjustWallet: 'Ajuster le portefeuille',
+    addManualPrint: 'Ajouter un tirage manuel',
+    manageMembers: 'Gérer les membres du centre de coûts',
+    currentBalance: 'Solde personnel',
+    transactions: 'Opérations',
+    personalTransactions: 'Transactions personnelles',
+    costCenters: 'Centres de coûts',
+    availableForPrinting: 'Disponible pour l\'attribution d\'impression',
+    costCenterName: 'Nom',
+    budgetType: 'Type de budget',
+    monthlyBudget: 'Budget mensuel',
+    totalBudget: 'Budget total',
+    noBudget: 'Aucun budget',
+    create: 'Créer',
+    selectUser: 'Sélectionner un utilisateur',
+    transactionType: 'Type',
+    amount: 'Montant',
+    noCostCenter: 'Aucun centre de coûts',
+    descriptionOptional: 'Description (facultative)',
+    applyAdjustment: 'Appliquer l\'ajustement',
+    memberCanPrint: 'Le membre peut imprimer',
+    addMember: 'Ajouter un membre',
+    canPrint: 'Peut imprimer',
+    noMembers: 'Aucun membre assigné.',
+    editCostCenter: 'Modifier le centre de coûts',
+    myCostCenters: 'Mes centres de coûts',
+    costCentersHint: 'Vérifier les limites budgétaires et garder les coûts sous contrôle',
+    noCostCenters: 'Aucun centre de coûts trouvé.',
+    owner: 'Propriétaire',
+    balance: 'Solde',
+    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
@@ -2268,6 +2359,21 @@ export default {
     useTls: 'Utiliser TLS',
     enableMetricsEndpoint: 'Activer l\'endpoint Metrics',
     availableMetrics: 'Metrics disponibles',
+    billingEnabled: 'Activer la facturation',
+    billingEnabledDescription: 'Facturer les utilisateurs pour les impressions et activer les fonctionnalités financières',
+    printerKillSwitch: 'Interrupteur d\'arrêt automatique',
+    printerKillSwitchDescription: 'Arrêter immédiatement les impressions qui démarrent sans autorisation',
+    financeBudgetReset: 'Réinitialisation mensuelle du budget financier',
+    financeBudgetResetDay: 'Jour de réinitialisation',
+    financeBudgetResetDayHelp: 'Pour les mois courts, la réinitialisation utilise le dernier jour du mois.',
+    financeBudgetResetTimezone: 'Fuseau horaire de réinitialisation',
+    financeBudgetResetTimezoneHelp: 'Le fuseau horaire utilisé pour déterminer le moment de la réinitialisation du budget chaque mois.',
+    rebuildLedger: 'Reconstruire le grand livre du portefeuille',
+    rebuildLedgerStarted: 'Reconstruction du grand livre commencée',
+    rebuildLedgerConfirmTitle: 'Reconstruire le grand livre du portefeuille?',
+    rebuildLedgerConfirmMessage:
+      'Cela reconstruira le grand livre du portefeuille pour réparer les valeurs de solde historiques. Exécutez ceci uniquement si vous savez ce que vous faites.',
+    rebuildLedgerInProgress: 'Reconstruction en cours de démarrage...',
     editUser: 'Modifier l\'utilisateur',
     deleteUserTitle: 'Supprimer l\'utilisateur',
     groupName: 'Nom du groupe',
@@ -4774,6 +4880,7 @@ export default {
     staggerTotal: 'total : {{minutes}} min',
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
     gcodeInjection: 'Injecter le G-code auto-impression',
+    insufficientBudget: 'Budget insuffisant',
   },
 
   // Backup

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Manutenzione',
     projects: 'Progetti',
     inventory: 'Filamento',
+    finance: 'Finanze',
     files: 'File',
     makerworld: 'MakerWorld',
     notifications: 'Notifiche',
@@ -124,6 +125,96 @@ export default {
     duplicate: 'Duplica',
     left: 'Sinistra',
     right: 'Destra',
+    user: 'Utente',
+    run: 'Esegui',
+    running: 'In esecuzione...',
+  },
+
+  finance: {
+    allTypes: 'Tutti i tipi',
+    allCostCenters: 'Tutti i centri di costo',
+    title: 'Finanza',
+    subtitle: 'Portafoglio, transazioni personali e centri di costo',
+    noAccess: 'Non hai il permesso di visualizzare i dati finanziari.',
+    personalView: 'Vista personale',
+    adminView: 'Vista amministratore',
+    createCostCenter: 'Crea centro di costo',
+    adjustWallet: 'Regola portafoglio',
+    addManualPrint: 'Aggiungi stampa manuale',
+    manageMembers: 'Gestisci membri del centro di costo',
+    currentBalance: 'Saldo personale',
+    transactions: 'Transazioni',
+    personalTransactions: 'Transazioni personali',
+    costCenters: 'Centri di costo',
+    availableForPrinting: 'Disponibile per l\'assegnazione stampa',
+    costCenterName: 'Nome',
+    budgetType: 'Tipo di budget',
+    monthlyBudget: 'Budget mensile',
+    totalBudget: 'Budget totale',
+    noBudget: 'Nessun budget',
+    create: 'Crea',
+    selectUser: 'Seleziona utente',
+    transactionType: 'Tipo',
+    amount: 'Importo',
+    noCostCenter: 'Nessun centro di costo',
+    descriptionOptional: 'Descrizione (facoltativa)',
+    applyAdjustment: 'Applica rettifica',
+    memberCanPrint: 'Il membro può stampare',
+    addMember: 'Aggiungi membro',
+    canPrint: 'Può stampare',
+    noMembers: 'Nessun membro assegnato.',
+    editCostCenter: 'Modifica centro di costo',
+    myCostCenters: 'I miei centri di costo',
+    costCentersHint: 'Controlla i limiti di budget e mantieni i costi sotto controllo',
+    noCostCenters: 'Nessun centro di costo trovato.',
+    owner: 'Proprietario',
+    balance: 'Saldo',
+    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
@@ -2268,6 +2359,21 @@ export default {
     useTls: 'Usa TLS',
     enableMetricsEndpoint: 'Abilita endpoint metriche',
     availableMetrics: 'Metriche disponibili',
+    billingEnabled: 'Abilita fatturazione',
+    billingEnabledDescription: 'Addebita agli utenti le stampe e abilita le funzioni finanziarie',
+    printerKillSwitch: 'Arresto automatico della stampa',
+    printerKillSwitchDescription: 'Interrompi immediatamente le stampe non autorizzate',
+    financeBudgetReset: 'Azzeramento mensile del budget finanziario',
+    financeBudgetResetDay: 'Giorno di azzeramento',
+    financeBudgetResetDayHelp: "Per i mesi brevi, l'azzeramento utilizza l'ultimo giorno del mese.",
+    financeBudgetResetTimezone: 'Fuso orario di azzeramento',
+    financeBudgetResetTimezoneHelp: "L'inizio della finestra di bilancio viene calcolato in questo fuso orario.",
+    rebuildLedger: 'Ricostruisci il ledger del portafoglio',
+    rebuildLedgerStarted: 'Ricostruzione del ledger avviata',
+    rebuildLedgerConfirmTitle: 'Ricostruire il ledger del portafoglio?',
+    rebuildLedgerConfirmMessage:
+      'Questo ricostruirà il ledger del portafoglio per riparare i valori di saldo storici. Eseguire solo se sai cosa stai facendo.',
+    rebuildLedgerInProgress: 'Avvio della ricostruzione...',
     editUser: 'Modifica utente',
     deleteUserTitle: 'Elimina utente',
     groupName: 'Nome gruppo',
@@ -4773,6 +4879,7 @@ export default {
     staggerTotal: 'totale: {{minutes}} min',
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     gcodeInjection: 'Inietta G-code auto-stampa',
+    insufficientBudget: 'Budget insufficiente',
   },
 
   // Backup

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'メンテナンス',
     projects: 'プロジェクト',
     inventory: 'フィラメント',
+    finance: 'ファイナンス',
     files: 'ファイル管理',
     makerworld: 'MakerWorld',
     notifications: '通知',
@@ -124,6 +125,96 @@ export default {
     duplicate: '複製',
     left: '左',
     right: '右',
+    user: 'ユーザー',
+    run: '実行',
+    running: '実行中...',
+  },
+
+  finance: {
+    allTypes: 'すべてのタイプ',
+    allCostCenters: 'すべてのコストセンター',
+    title: 'ファイナンス',
+    subtitle: 'ウォレット、個人トランザクション、およびコストセンター',
+    noAccess: '財務データを表示する権限がありません。',
+    personalView: '個人ビュー',
+    adminView: '管理者ビュー',
+    createCostCenter: 'コストセンターを作成',
+    adjustWallet: 'ウォレットを調整',
+    addManualPrint: '手動プリントを追加',
+    manageMembers: 'コストセンターメンバーを管理',
+    currentBalance: '個人残高',
+    transactions: 'トランザクション',
+    personalTransactions: '個人トランザクション',
+    costCenters: 'コストセンター',
+    availableForPrinting: 'プリント割り当てが可能',
+    costCenterName: '名前',
+    budgetType: '予算タイプ',
+    monthlyBudget: '月間予算',
+    totalBudget: '総予算',
+    noBudget: '予算なし',
+    create: '作成',
+    selectUser: 'ユーザーを選択',
+    transactionType: 'タイプ',
+    amount: '金額',
+    noCostCenter: 'コストセンターなし',
+    descriptionOptional: '説明(オプション)',
+    applyAdjustment: '調整を適用',
+    memberCanPrint: 'メンバーはプリント可能',
+    addMember: 'メンバーを追加',
+    canPrint: 'プリント可能',
+    noMembers: '割り当てられたメンバーはありません。',
+    editCostCenter: 'コストセンターを編集',
+    myCostCenters: 'マイコストセンター',
+    costCentersHint: '予算限度額を確認し、コストを管理下に置きます',
+    noCostCenters: 'コストセンターが見つかりません。',
+    owner: '所有者',
+    balance: '残高',
+    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: {
@@ -2311,6 +2402,21 @@ export default {
     useTls: 'TLSを使用',
     enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
     availableMetrics: '利用可能なメトリクス',
+    billingEnabled: '課金を有効にする',
+    billingEnabledDescription: 'ユーザーにプリント料金を請求し、財務機能を有効にします',
+    printerKillSwitch: '印刷の自動停止',
+    printerKillSwitchDescription: '承認されていない印刷を即座に停止する',
+    financeBudgetReset: '月次財務予算のリセット',
+    financeBudgetResetDay: 'リセット日',
+    financeBudgetResetDayHelp: "短い月の場合、リセットは月の最終日を使用します。",
+    financeBudgetResetTimezone: 'リセットタイムゾーン',
+    financeBudgetResetTimezoneHelp: "L'inizio della finestra di bilancio viene calcolato in questo fuso orario.",
+    rebuildLedger: 'ウォレットレジャーを再構築',
+    rebuildLedgerStarted: 'レジャー再構築が開始されました',
+    rebuildLedgerConfirmTitle: 'ウォレットレジャーを再構築しますか?',
+    rebuildLedgerConfirmMessage:
+      'これにより、ウォレットレジャーが再構築され、履歴残高値が修復されます。何をしているかを確認した時にのみこれを実行してください。',
+    rebuildLedgerInProgress: '再構築を開始中...',
     editUser: 'ユーザーを編集',
     deleteUserTitle: 'ユーザーを削除',
     groupName: 'グループ名',
@@ -4785,6 +4891,7 @@ export default {
     staggerTotal: '合計: {{minutes}}分',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     gcodeInjection: '自動印刷G-codeを挿入',
+    insufficientBudget: '予算が不足しています',
   },
 
   // Backup

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

@@ -8,6 +8,7 @@ export default {
     maintenance: '유지보수',
     projects: '프로젝트',
     inventory: '필라멘트',
+    finance: '재무',
     files: '파일 관리자',
     makerworld: 'MakerWorld',
     notifications: '알림',
@@ -120,7 +121,96 @@ export default {
     create: '만들기',
     duplicate: '복제',
     left: '왼쪽',
-    right: '오른쪽'
+    right: '오른쪽',
+    user: '사용자',
+    run: '실행',
+    running: '실행 중...'
+  },
+  finance: {
+    allTypes: '모든 유형',
+    allCostCenters: '모든 비용 센터',
+    title: '재무',
+    subtitle: '지갑, 개인 거래 및 비용 센터',
+    noAccess: '재무 데이터를 볼 권한이 없습니다.',
+    personalView: '개인 보기',
+    adminView: '관리자 보기',
+    createCostCenter: '비용 센터 만들기',
+    adjustWallet: '지갑 조정',
+    addManualPrint: '수동 인쇄 추가',
+    manageMembers: '비용 센터 구성원 관리',
+    currentBalance: '개인 잔액',
+    transactions: '거래',
+    personalTransactions: '개인 거래',
+    costCenters: '비용 센터',
+    availableForPrinting: '인쇄 할당 가능',
+    costCenterName: '이름',
+    budgetType: '예산 유형',
+    monthlyBudget: '월 예산',
+    totalBudget: '총예산',
+    noBudget: '예산 없음',
+    create: '만들기',
+    selectUser: '사용자 선택',
+    transactionType: '유형',
+    amount: '금액',
+    noCostCenter: '비용 센터 없음',
+    descriptionOptional: '설명(선택 사항)',
+    applyAdjustment: '조정 적용',
+    memberCanPrint: '구성원이 인쇄할 수 있음',
+    addMember: '구성원 추가',
+    canPrint: '인쇄 가능',
+    noMembers: '할당된 구성원이 없습니다.',
+    editCostCenter: '비용 센터 편집',
+    myCostCenters: '내 비용 센터',
+    costCentersHint: '예산 한도를 검토하고 비용을 관리하세요',
+    noCostCenters: '비용 센터를 찾을 수 없습니다.',
+    owner: '소유자',
+    balance: '잔액',
+    budget: '예산',
+    shared: '공유',
+    cannotEditPrivateCostCenter: '비공개 비용 센터는 여기에서 편집할 수 없습니다',
+    recentTransactions: '최근 거래',
+    transactionsHint: '유형 및 비용 센터별 필터링',
+    first: '처음',
+    prev: '이전',
+    next: '다음',
+    last: '마지막',
+    pageNumberOf: '{{total}}페이지 중 {{page}}페이지',
+    noTransactions: '사용 가능한 거래가 없습니다.',
+    noTransactionsForFilter: '선택한 필터와 일치하는 거래가 없습니다.',
+    costCenter: '비용 센터',
+    balanceAfter: '거래 후 잔액',
+    userWithId: '사용자 #{{id}}',
+    partial: '일부',
+    editTransaction: '거래 편집',
+    selectCostCenter: '비용 센터 선택...',
+    costCenterRequired: '비용 센터를 선택하세요',
+    amountExample: '예: 4.00',
+    manualAdjustmentExample: '예: 수동 조정',
+    userRequired: '사용자를 선택하세요',
+    amountInvalid: '금액은 유효한 숫자여야 합니다',
+    createdCostCenter: '비용 센터를 만들었습니다',
+    createCostCenterFailed: '비용 센터를 만들지 못했습니다',
+    transactionDeleted: '거래를 삭제했습니다',
+    deleteTransactionFailed: '거래를 삭제하지 못했습니다',
+    transactionEdited: '거래를 업데이트하고 원장을 다시 계산했습니다',
+    editTransactionFailed: '거래를 편집하지 못했습니다',
+    manualPrintCreated: '수동 인쇄 비용을 추가하고 원장을 다시 계산했습니다',
+    manualPrintFailed: '수동 인쇄를 만들지 못했습니다',
+    memberSaved: '구성원을 저장했습니다',
+    memberSaveFailed: '구성원을 저장하지 못했습니다',
+    memberRemoved: '구성원을 제거했습니다',
+    memberRemoveFailed: '구성원을 제거하지 못했습니다',
+    costCenterNameRequired: '비용 센터 이름은 필수입니다',
+    costCenterUpdated: '비용 센터를 업데이트했습니다',
+    costCenterUpdateFailed: '비용 센터를 업데이트하지 못했습니다',
+    confirmDeleteCostCenter: '비용 센터 "{{name}}"을(를) 삭제하시겠습니까?',
+    costCenterDeleted: '비용 센터를 삭제했습니다',
+    costCenterDeleteFailed: '비용 센터를 삭제하지 못했습니다',
+    deleteTransactionConfirm: '이 거래를 삭제하시겠습니까? 잔액은 자동으로 다시 계산됩니다.',
+    deposit: '입금',
+    withdraw: '출금',
+    printCharge: '인쇄 비용',
+    deleteTransaction: '거래 삭제',
   },
   printers: {
     title: '프린터',
@@ -2167,6 +2257,20 @@ export default {
     },
     externalCameras: '외부 카메라',
     costTracking: '비용 추적',
+    billingEnabled: '결제 기능 사용',
+    billingEnabledDescription: '사용자에게 인쇄 비용을 청구하고 재무 기능을 사용합니다',
+    printerKillSwitch: '무단 인쇄 자동 중지',
+    printerKillSwitchDescription: '승인 없이 시작된 인쇄를 즉시 중지합니다.',
+    financeBudgetReset: '월별 재무 예산 초기화',
+    financeBudgetResetDay: '초기화 날짜',
+    financeBudgetResetDayHelp: '해당 날짜가 없는 짧은 달에는 그 달의 마지막 날에 초기화합니다.',
+    financeBudgetResetTimezone: '초기화 시간대',
+    financeBudgetResetTimezoneHelp: '예산 기간의 시작은 이 시간대를 기준으로 계산됩니다.',
+    rebuildLedger: '지갑 원장 재구성',
+    rebuildLedgerStarted: '원장 재구성을 시작했습니다',
+    rebuildLedgerConfirmTitle: '지갑 원장을 재구성하시겠습니까?',
+    rebuildLedgerConfirmMessage: '과거 잔액 값을 복구하기 위해 지갑 원장을 재구성합니다. 수행 내용을 정확히 아는 경우에만 실행하세요.',
+    rebuildLedgerInProgress: '재구성 시작 중...',
     printsOnly: '인쇄만',
     totalConsumption: '총 소비',
     dataManagement: '데이터 관리',
@@ -4556,7 +4660,8 @@ export default {
     staggerLastGroup: '마지막 그룹: {{count}}',
     staggerTotal: '합계: {{minutes}}분',
     staggerToPrinters: '{{count}}대 프린터에 분산',
-    gcodeInjection: '자동 인쇄 G-code 삽입'
+    gcodeInjection: '자동 인쇄 G-code 삽입',
+    insufficientBudget: '예산 부족'
   },
   backup: {
     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',
     projects: 'Projetos',
     inventory: 'Inventário',
+    finance: 'Finanças',
     files: 'Gerenciador de Arquivos',
     makerworld: 'MakerWorld',
     notifications: 'Notificações',
@@ -124,6 +125,96 @@ export default {
     duplicate: 'Duplicar',
     left: 'Esquerda',
     right: 'Direita',
+    user: 'Usuário',
+    run: 'Executar',
+    running: 'Executando...',
+  },
+
+  finance: {
+    allTypes: 'Todos os tipos',
+    allCostCenters: 'Todos os centros de custo',
+    title: 'Finanças',
+    subtitle: 'Carteira, transações pessoais e centros de custo',
+    noAccess: 'Você não tem permissão para visualizar dados financeiros.',
+    personalView: 'Visualização pessoal',
+    adminView: 'Visualização do administrador',
+    createCostCenter: 'Criar centro de custo',
+    adjustWallet: 'Ajustar carteira',
+    addManualPrint: 'Adicionar impressão manual',
+    manageMembers: 'Gerenciar membros do centro de custo',
+    currentBalance: 'Saldo pessoal',
+    transactions: 'Transações',
+    personalTransactions: 'Transações pessoais',
+    costCenters: 'Centros de custo',
+    availableForPrinting: 'Disponível para atribuição de impressão',
+    costCenterName: 'Nome',
+    budgetType: 'Tipo de orçamento',
+    monthlyBudget: 'Orçamento mensal',
+    totalBudget: 'Orçamento total',
+    noBudget: 'Sem orçamento',
+    create: 'Criar',
+    selectUser: 'Selecionar usuário',
+    transactionType: 'Tipo',
+    amount: 'Valor',
+    noCostCenter: 'Sem centro de custo',
+    descriptionOptional: 'Descrição (opcional)',
+    applyAdjustment: 'Aplicar ajuste',
+    memberCanPrint: 'Membro pode imprimir',
+    addMember: 'Adicionar membro',
+    canPrint: 'Pode imprimir',
+    noMembers: 'Nenhum membro atribuído.',
+    editCostCenter: 'Editar centro de custo',
+    myCostCenters: 'Meus centros de custo',
+    costCentersHint: 'Revise os limites de orçamento e mantenha os custos sob controle',
+    noCostCenters: 'Nenhum centro de custo encontrado.',
+    owner: 'Proprietário',
+    balance: 'Saldo',
+    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
@@ -2268,6 +2359,21 @@ export default {
     useTls: 'Usar TLS',
     enableMetricsEndpoint: 'Ativar Endpoint de Métricas',
     availableMetrics: 'Métricas Disponíveis',
+    billingEnabled: 'Habilitar cobrança',
+    billingEnabledDescription: 'Cobrar dos usuários por impressões e habilitar recursos de financeiro',
+    printerKillSwitch: 'Parada Automática de Impressão',
+    printerKillSwitchDescription: 'Interromper imediatamente impressões não autorizadas',
+    financeBudgetReset: 'Redefinir Orçamento Financeiro Mensal',
+    financeBudgetResetDay: 'Dia de Redefinição',
+    financeBudgetResetDayHelp: 'Para meses curtos, a redefinição utiliza o último dia do mês.',
+    financeBudgetResetTimezone: 'Fuso Horário de Redefinição',
+    financeBudgetResetTimezoneHelp: 'Der Budget-Fenster-Start wird in dieser Zeitzone berechnet.',
+    rebuildLedger: 'Reconstruir razão da carteira',
+    rebuildLedgerStarted: 'Reconstrução da razão iniciada',
+    rebuildLedgerConfirmTitle: 'Reconstruir razão da carteira?',
+    rebuildLedgerConfirmMessage:
+      'Isto reconstruirá a razão da carteira para reparar valores de saldo histórico. Execute isso apenas se você souber o que está fazendo.',
+    rebuildLedgerInProgress: 'Iniciando reconstrução...',
     editUser: 'Editar Usuário',
     deleteUserTitle: 'Excluir Usuário',
     groupName: 'Nome do Grupo',
@@ -4773,6 +4879,7 @@ export default {
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Escalonar para {{count}} impressoras',
     gcodeInjection: 'Injetar G-code de auto-impressão',
+    insufficientBudget: 'Orçamento insuficiente',
   },
 
   // Backup

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

@@ -8,6 +8,7 @@ export default {
     maintenance: "Обслуживание",
     projects: "Проекты",
     inventory: "Филамент",
+    finance: "Финансы",
     files: "Файловый менеджер",
     makerworld: "MakerWorld",
     notifications: "Уведомления",
@@ -121,6 +122,95 @@ export default {
     duplicate: "Дублировать",
     left: "Левый",
     right: "Правый",
+    user: "Пользователь",
+    run: "Запустить",
+    running: "Выполняется...",
+  },
+  finance: {
+    allTypes: "Все типы",
+    allCostCenters: "Все центры затрат",
+    title: "Финансы",
+    subtitle: "Кошелёк, личные операции и центры затрат",
+    noAccess: "У вас нет разрешения на просмотр финансовых данных.",
+    personalView: "Личный режим",
+    adminView: "Режим администратора",
+    createCostCenter: "Создать центр затрат",
+    adjustWallet: "Изменить кошелёк",
+    addManualPrint: "Добавить печать вручную",
+    manageMembers: "Управление участниками центра затрат",
+    currentBalance: "Личный баланс",
+    transactions: "Операции",
+    personalTransactions: "Личные операции",
+    costCenters: "Центры затрат",
+    availableForPrinting: "Доступен для назначения печати",
+    costCenterName: "Название",
+    budgetType: "Тип бюджета",
+    monthlyBudget: "Месячный бюджет",
+    totalBudget: "Общий бюджет",
+    noBudget: "Без бюджета",
+    create: "Создать",
+    selectUser: "Выберите пользователя",
+    transactionType: "Тип",
+    amount: "Сумма",
+    noCostCenter: "Без центра затрат",
+    descriptionOptional: "Описание (необязательно)",
+    applyAdjustment: "Применить изменение",
+    memberCanPrint: "Участник может печатать",
+    addMember: "Добавить участника",
+    canPrint: "Может печатать",
+    noMembers: "Участники не назначены.",
+    editCostCenter: "Изменить центр затрат",
+    myCostCenters: "Мои центры затрат",
+    costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
+    noCostCenters: "Центры затрат не найдены.",
+    owner: "Владелец",
+    balance: "Баланс",
+    budget: "Бюджет",
+    shared: "Общий",
+    cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
+    recentTransactions: "Недавние операции",
+    transactionsHint: "Фильтр по типу и центру затрат",
+    first: "Первая",
+    prev: "Предыдущая",
+    next: "Следующая",
+    last: "Последняя",
+    pageNumberOf: "Страница {{page}} из {{total}}",
+    noTransactions: "Нет доступных операций.",
+    noTransactionsForFilter: "Нет операций, соответствующих выбранным фильтрам.",
+    costCenter: "Центр затрат",
+    balanceAfter: "Баланс после операции",
+    userWithId: "Пользователь №{{id}}",
+    partial: "Частично",
+    editTransaction: "Изменить операцию",
+    selectCostCenter: "Выберите центр затрат...",
+    costCenterRequired: "Выберите центр затрат",
+    amountExample: "например, 4,00",
+    manualAdjustmentExample: "например, ручная корректировка",
+    userRequired: "Выберите пользователя",
+    amountInvalid: "Сумма должна быть допустимым числом",
+    createdCostCenter: "Центр затрат создан",
+    createCostCenterFailed: "Не удалось создать центр затрат",
+    transactionDeleted: "Операция удалена",
+    deleteTransactionFailed: "Не удалось удалить операцию",
+    transactionEdited: "Операция обновлена, бухгалтерская книга пересчитана",
+    editTransactionFailed: "Не удалось изменить операцию",
+    manualPrintCreated: "Расход на печать добавлен, бухгалтерская книга пересчитана",
+    manualPrintFailed: "Не удалось добавить печать вручную",
+    memberSaved: "Участник сохранён",
+    memberSaveFailed: "Не удалось сохранить участника",
+    memberRemoved: "Участник удалён",
+    memberRemoveFailed: "Не удалось удалить участника",
+    costCenterNameRequired: "Необходимо указать название центра затрат",
+    costCenterUpdated: "Центр затрат обновлён",
+    costCenterUpdateFailed: "Не удалось обновить центр затрат",
+    confirmDeleteCostCenter: "Удалить центр затрат «{{name}}»?",
+    costCenterDeleted: "Центр затрат удалён",
+    costCenterDeleteFailed: "Не удалось удалить центр затрат",
+    deleteTransactionConfirm: "Удалить эту операцию? Балансы будут пересчитаны автоматически.",
+    deposit: "Пополнение",
+    withdraw: "Списание",
+    printCharge: "Расход на печать",
+    deleteTransaction: "Удалить операцию",
   },
   printers: {
     title: "Принтеры",
@@ -2168,6 +2258,20 @@ export default {
     },
     externalCameras: "Внешние камеры",
     costTracking: "Учёт затрат",
+    billingEnabled: "Включить расчёты",
+    billingEnabledDescription: "Списывать с пользователей стоимость печати и включить финансовые функции",
+    printerKillSwitch: "Автоостановка несанкционированной печати",
+    printerKillSwitchDescription: "Немедленно останавливает печать, начатую без разрешения.",
+    financeBudgetReset: "Ежемесячный сброс финансового бюджета",
+    financeBudgetResetDay: "День сброса",
+    financeBudgetResetDayHelp: "В коротких месяцах сброс выполняется в последний день месяца.",
+    financeBudgetResetTimezone: "Часовой пояс сброса",
+    financeBudgetResetTimezoneHelp: "Начало бюджетного периода рассчитывается в этом часовом поясе.",
+    rebuildLedger: "Пересчитать книгу кошелька",
+    rebuildLedgerStarted: "Пересчёт книги запущен",
+    rebuildLedgerConfirmTitle: "Пересчитать книгу кошелька?",
+    rebuildLedgerConfirmMessage: "Книга кошелька будет пересчитана для исправления исторических значений баланса. Запускайте это действие только если понимаете его последствия.",
+    rebuildLedgerInProgress: "Запуск пересчёта...",
     printsOnly: "Только печать",
     totalConsumption: "Общее потребление",
     dataManagement: "Управление данными",
@@ -4546,6 +4650,7 @@ export default {
     staggerTotal: "всего: {{minutes}} мин",
     staggerToPrinters: "Распределить запуск для {{count}} принтеров",
     gcodeInjection: "Добавить G-code автозапуска",
+    insufficientBudget: "Недостаточно бюджета",
   },
   backup: {
     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',
     projects: 'Projeler',
     inventory: 'Filament',
+    finance: 'Finans',
     files: 'Dosya Yöneticisi',
     makerworld: 'MakerWorld',
     notifications: 'Bildirimler',
@@ -124,6 +125,96 @@ export default {
     duplicate: 'Çoğalt',
     left: 'Sol',
     right: 'Sağ',
+    user: 'Kullanıcı',
+    run: 'Çalıştır',
+    running: 'Çalışıyor...',
+  },
+
+  finance: {
+    allTypes: 'Tüm türler',
+    allCostCenters: 'Tüm masraf merkezleri',
+    title: 'Finans',
+    subtitle: 'Cüzdan, kişisel işlemler ve masraf merkezleri',
+    noAccess: 'Finans verilerini görüntüleme izniniz yok.',
+    personalView: 'Kişisel görünüm',
+    adminView: 'Yönetici görünümü',
+    createCostCenter: 'Masraf merkezi oluştur',
+    adjustWallet: 'Cüzdanı ayarla',
+    addManualPrint: 'Manuel baskı ekle',
+    manageMembers: 'Masraf merkezi üyelerini yönet',
+    currentBalance: 'Kişisel bakiye',
+    transactions: 'İşlemler',
+    personalTransactions: 'Kişisel işlemler',
+    costCenters: 'Masraf merkezleri',
+    availableForPrinting: 'Baskı ataması için kullanılabilir',
+    costCenterName: 'Ad',
+    budgetType: 'Bütçe türü',
+    monthlyBudget: 'Aylık bütçe',
+    totalBudget: 'Toplam bütçe',
+    noBudget: 'Bütçe yok',
+    create: 'Oluştur',
+    selectUser: 'Kullanıcı seç',
+    transactionType: 'Tür',
+    amount: 'Tutar',
+    noCostCenter: 'Masraf merkezi yok',
+    descriptionOptional: 'Açıklama (isteğe bağlı)',
+    applyAdjustment: 'Ayarlamayı uygula',
+    memberCanPrint: 'Üye baskı yapabilir',
+    addMember: 'Üye ekle',
+    canPrint: 'Baskı yapabilir',
+    noMembers: 'Atanmış üye yok.',
+    editCostCenter: 'Masraf merkezini düzenle',
+    myCostCenters: 'Masraf merkezlerim',
+    costCentersHint: 'Bütçe sınırlarını gözden geçirin ve maliyetleri kontrol altında tutun',
+    noCostCenters: 'Masraf merkezi bulunamadı.',
+    owner: 'Sahip',
+    balance: '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ı
@@ -2298,6 +2389,20 @@ export default {
     },
     externalCameras: 'Harici Kameralar',
     costTracking: 'Maliyet Takibi',
+    billingEnabled: 'Faturalandırmayı etkinleştir',
+    billingEnabledDescription: 'Kullanıcılardan baskı ücreti alın ve finans özelliklerini etkinleştirin',
+    printerKillSwitch: 'Yetkisiz baskıyı otomatik durdurma',
+    printerKillSwitchDescription: 'Yetkilendirme olmadan başlayan baskıları hemen durdurur.',
+    financeBudgetReset: 'Aylık finans bütçesi sıfırlama',
+    financeBudgetResetDay: 'Sıfırlama günü',
+    financeBudgetResetDayHelp: 'Kısa aylarda sıfırlama ayın son gününde yapılır.',
+    financeBudgetResetTimezone: 'Sıfırlama saat dilimi',
+    financeBudgetResetTimezoneHelp: 'Bütçe döneminin başlangıcı bu saat dilimine göre hesaplanır.',
+    rebuildLedger: 'Cüzdan hesap defterini yeniden oluştur',
+    rebuildLedgerStarted: 'Hesap defteri yeniden oluşturulmaya başlandı',
+    rebuildLedgerConfirmTitle: 'Cüzdan hesap defteri yeniden oluşturulsun mu?',
+    rebuildLedgerConfirmMessage: 'Geçmiş bakiye değerlerini onarmak için cüzdan hesap defteri yeniden oluşturulacaktır. Bunu yalnızca ne yaptığınızı biliyorsanız çalıştırın.',
+    rebuildLedgerInProgress: 'Yeniden oluşturma başlatılıyor...',
     printsOnly: 'Yalnızca Baskılar',
     totalConsumption: 'Toplam Tüketim',
     dataManagement: 'Veri Yönetimi',
@@ -4763,6 +4868,7 @@ export default {
     staggerTotal: 'toplam: {{minutes}} dk',
     staggerToPrinters: '{{count}} yazıcıya kademelendir',
     gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
+    insufficientBudget: 'Yetersiz bütçe',
   },
 
   // Yedekleme

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

@@ -9,6 +9,7 @@ export default {
     maintenance: "Технічне обслуговування",
     projects: "Проєкти",
     inventory: "Філамент",
+    finance: "Фінанси",
     files: "Менеджер файлів",
     makerworld: "MakerWorld",
     notifications: "Сповіщення",
@@ -124,6 +125,96 @@ export default {
     duplicate: "Дублювати",
     left: "Ліворуч",
     right: "Праворуч",
+    user: "Користувач",
+    run: "Запустити",
+    running: "Виконується...",
+  },
+
+  finance: {
+    allTypes: "Усі типи",
+    allCostCenters: "Усі центри витрат",
+    title: "Фінанси",
+    subtitle: "Гаманець, особисті операції та центри витрат",
+    noAccess: "У вас немає дозволу на перегляд фінансових даних.",
+    personalView: "Особистий режим",
+    adminView: "Режим адміністратора",
+    createCostCenter: "Створити центр витрат",
+    adjustWallet: "Скоригувати гаманець",
+    addManualPrint: "Додати ручний друк",
+    manageMembers: "Керувати учасниками центру витрат",
+    currentBalance: "Особистий баланс",
+    transactions: "Операції",
+    personalTransactions: "Особисті операції",
+    costCenters: "Центри витрат",
+    availableForPrinting: "Доступний для призначення друку",
+    costCenterName: "Назва",
+    budgetType: "Тип бюджету",
+    monthlyBudget: "Місячний бюджет",
+    totalBudget: "Загальний бюджет",
+    noBudget: "Без бюджету",
+    create: "Створити",
+    selectUser: "Виберіть користувача",
+    transactionType: "Тип",
+    amount: "Сума",
+    noCostCenter: "Без центру витрат",
+    descriptionOptional: "Опис (необов’язково)",
+    applyAdjustment: "Застосувати коригування",
+    memberCanPrint: "Учасник може друкувати",
+    addMember: "Додати учасника",
+    canPrint: "Може друкувати",
+    noMembers: "Учасників не призначено.",
+    editCostCenter: "Редагувати центр витрат",
+    myCostCenters: "Мої центри витрат",
+    costCentersHint: "Переглядайте бюджетні ліміти та контролюйте витрати",
+    noCostCenters: "Центрів витрат не знайдено.",
+    owner: "Власник",
+    balance: "Баланс",
+    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
@@ -2313,6 +2404,20 @@ export default {
     },
     externalCameras: "Зовнішні камери",
     costTracking: "Відстеження витрат",
+    billingEnabled: "Увімкнути розрахунки",
+    billingEnabledDescription: "Стягувати з користувачів вартість друку та ввімкнути фінансові функції",
+    printerKillSwitch: "Автозупинка несанкціонованого друку",
+    printerKillSwitchDescription: "Негайно зупиняє друк, розпочатий без дозволу.",
+    financeBudgetReset: "Щомісячне скидання фінансового бюджету",
+    financeBudgetResetDay: "День скидання",
+    financeBudgetResetDayHelp: "У коротких місяцях скидання виконується в останній день місяця.",
+    financeBudgetResetTimezone: "Часовий пояс скидання",
+    financeBudgetResetTimezoneHelp: "Початок бюджетного періоду обчислюється в цьому часовому поясі.",
+    rebuildLedger: "Перебудувати книгу гаманця",
+    rebuildLedgerStarted: "Перебудову книги розпочато",
+    rebuildLedgerConfirmTitle: "Перебудувати книгу гаманця?",
+    rebuildLedgerConfirmMessage: "Книгу гаманця буде перебудовано для виправлення історичних значень балансу. Запускайте цю дію лише якщо розумієте її наслідки.",
+    rebuildLedgerInProgress: "Запуск перебудови...",
     printsOnly: "Лише друк",
     totalConsumption: "Загальне споживання",
     dataManagement: "Управління даними",
@@ -4828,6 +4933,7 @@ export default {
     staggerTotal: "всього: {{minutes}} хв",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     gcodeInjection: "Додати G-код автоматичного друку",
+    insufficientBudget: "Недостатньо бюджету",
   },
 
   // Backup

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

@@ -8,6 +8,7 @@ export default {
     profiles: '配置文件',
     maintenance: '维护',
     projects: '项目',
+    finance: '财务',
     inventory: '耗材',
     files: '文件管理器',
     makerworld: 'MakerWorld',
@@ -67,6 +68,7 @@ export default {
     actions: '操作',
     status: '状态',
     name: '名称',
+    user: '用户',
     description: '描述',
     date: '日期',
     time: '时间',
@@ -124,6 +126,95 @@ export default {
     duplicate: '复制',
     left: '左',
     right: '右',
+    run: '运行',
+    running: '运行中...',
+  },
+
+  finance: {
+    allTypes: '所有类型',
+    allCostCenters: '所有成本中心',
+    title: '财务',
+    subtitle: '钱包、个人交易和成本中心',
+    noAccess: '您没有权限查看财务数据。',
+    personalView: '个人视图',
+    adminView: '管理员视图',
+    createCostCenter: '创建成本中心',
+    adjustWallet: '调整钱包',
+    addManualPrint: '添加手动打印',
+    manageMembers: '管理成本中心成员',
+    currentBalance: '个人余额',
+    transactions: '交易',
+    personalTransactions: '个人交易',
+    costCenters: '成本中心',
+    availableForPrinting: '可用于打印分配',
+    costCenterName: '名称',
+    budgetType: '预算类型',
+    monthlyBudget: '月度预算',
+    totalBudget: '总预算',
+    noBudget: '无预算',
+    create: '创建',
+    selectUser: '选择用户',
+    transactionType: '类型',
+    amount: '金额',
+    noCostCenter: '无成本中心',
+    descriptionOptional: '描述(可选)',
+    applyAdjustment: '应用调整',
+    memberCanPrint: '成员可以打印',
+    addMember: '添加成员',
+    canPrint: '可以打印',
+    noMembers: '未分配任何成员。',
+    editCostCenter: '编辑成本中心',
+    myCostCenters: '我的成本中心',
+    costCentersHint: '审查预算限制并将成本控制在可控范围内',
+    noCostCenters: '未找到成本中心。',
+    owner: '所有者',
+    balance: '余额',
+    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
@@ -2313,6 +2404,21 @@ export default {
     useTls: '使用 TLS',
     enableMetricsEndpoint: '启用指标端点',
     availableMetrics: '可用指标',
+    billingEnabled: '启用账单',
+    billingEnabledDescription: '对用户的打印进行收费并启用财务功能',
+    printerKillSwitch: '打印机紧急停止开关',
+    printerKillSwitchDescription: '立即停止未经授权的打印',
+    financeBudgetReset: '财务预算月度重置',
+    financeBudgetResetDay: '重置日期',
+    financeBudgetResetDayHelp: '对于少数月份,重置使用该月的最后一天。',
+    financeBudgetResetTimezone: '重置时区',
+    financeBudgetResetTimezoneHelp: '预算窗口的开始时间在此时区计算。',
+    rebuildLedger: '重建钱包账本',
+    rebuildLedgerStarted: '钱包账本重建已启动',
+    rebuildLedgerConfirmTitle: '重建钱包账本?',
+    rebuildLedgerConfirmMessage:
+      '这将重建钱包账本以修复历史余额值。仅在您知道自己在做什么时才执行此操作。',
+    rebuildLedgerInProgress: '正在启动重建...',
     editUser: '编辑用户',
     deleteUserTitle: '删除用户',
     groupName: '组名称',
@@ -4773,6 +4879,7 @@ export default {
     staggerTotal: '共 {{minutes}} 分钟',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     gcodeInjection: '注入自动打印G-code',
+    insufficientBudget: '预算不足',
   },
 
   // Backup

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

@@ -8,6 +8,7 @@ export default {
     profiles: '設定檔案',
     maintenance: '維護',
     projects: '專案',
+    finance: '財務',
     inventory: '耗材',
     files: '檔案管理器',
     makerworld: 'MakerWorld',
@@ -124,6 +125,96 @@ export default {
     duplicate: '複製',
     left: '左',
     right: '右',
+    user: '使用者',
+    run: '執行',
+    running: '執行中...',
+  },
+
+  finance: {
+    allTypes: '所有類型',
+    allCostCenters: '所有成本中心',
+    title: '財務',
+    subtitle: '錢包、個人交易和成本中心',
+    noAccess: '您沒有權限查看財務資料。',
+    personalView: '個人檢視',
+    adminView: '管理員檢視',
+    createCostCenter: '建立成本中心',
+    adjustWallet: '調整錢包',
+    addManualPrint: '新增手動列印',
+    manageMembers: '管理成本中心成員',
+    currentBalance: '個人餘額',
+    transactions: '交易',
+    personalTransactions: '個人交易',
+    costCenters: '成本中心',
+    availableForPrinting: '可用於列印分配',
+    costCenterName: '名稱',
+    budgetType: '預算類型',
+    monthlyBudget: '月度預算',
+    totalBudget: '總預算',
+    noBudget: '無預算',
+    create: '建立',
+    selectUser: '選擇使用者',
+    transactionType: '類型',
+    amount: '金額',
+    noCostCenter: '無成本中心',
+    descriptionOptional: '說明(可選)',
+    applyAdjustment: '套用調整',
+    memberCanPrint: '成員可以列印',
+    addMember: '新增成員',
+    canPrint: '可以列印',
+    noMembers: '未分配任何成員。',
+    editCostCenter: '編輯成本中心',
+    myCostCenters: '我的成本中心',
+    costCentersHint: '檢視預算限制並將成本保持在控制範圍內',
+    noCostCenters: '未找到成本中心。',
+    owner: '擁有者',
+    balance: '餘額',
+    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
@@ -2313,6 +2404,21 @@ export default {
     useTls: '使用 TLS',
     enableMetricsEndpoint: '啟用指標端點',
     availableMetrics: '可用指標',
+    billingEnabled: '啟用帳單',
+    billingEnabledDescription: '向用戶收取列印費用並啟用財務功能',
+    printerKillSwitch: '列印機緊急停止開關',
+    printerKillSwitchDescription: '立即停止未經授權的列印',
+    financeBudgetReset: '財務預算月度重置',
+    financeBudgetResetDay: '重置日期',
+    financeBudgetResetDayHelp: '對於少數月份,重置使用該月的最後一天。',
+    financeBudgetResetTimezone: '重置時區',
+    financeBudgetResetTimezoneHelp: '預算視窗的開始時間在此時區計算。',
+    rebuildLedger: '重建錢包帳本',
+    rebuildLedgerStarted: '錢包帳本重建已啟動',
+    rebuildLedgerConfirmTitle: '重建錢包帳本?',
+    rebuildLedgerConfirmMessage:
+      '這將重建錢包帳本以修復歷史餘額值。僅在您知道自己在做什麼時才執行此操作。',
+    rebuildLedgerInProgress: '正在啟動重建...',
     editUser: '編輯使用者',
     deleteUserTitle: '刪除使用者',
     groupName: '群組名稱',
@@ -4773,6 +4879,7 @@ export default {
     staggerTotal: '總計:{{minutes}} 分鐘',
     staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
     gcodeInjection: '注入自動列印G-code',
+    insufficientBudget: '預算不足',
   },
 
   // 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 [settingsSearch, setSettingsSearch] = useState('');
   const [showLogViewer, setShowLogViewer] = useState(false);
+  const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
+  const [isRebuildLoading, setIsRebuildLoading] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
 
   // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
@@ -1085,7 +1087,11 @@ export function SettingsPage() {
       (baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
       (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
       (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
-      (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
+      (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24) ||
+      (baseline.billing_enabled ?? false) !== (localSettings.billing_enabled ?? false) ||
+      (baseline.printer_kill_switch_enabled ?? false) !== (localSettings.printer_kill_switch_enabled ?? false) ||
+      (baseline.finance_budget_reset_day ?? 1) !== (localSettings.finance_budget_reset_day ?? 1) ||
+      (baseline.finance_budget_reset_timezone ?? 'UTC') !== (localSettings.finance_budget_reset_timezone ?? 'UTC');
 
     if (!hasChanges) {
       return;
@@ -1190,6 +1196,10 @@ export function SettingsPage() {
         chamber_temp_presets: localSettings.chamber_temp_presets,
         fan_speed_presets: localSettings.fan_speed_presets,
         session_max_hours: localSettings.session_max_hours,
+        billing_enabled: localSettings.billing_enabled,
+        printer_kill_switch_enabled: localSettings.printer_kill_switch_enabled,
+        finance_budget_reset_day: localSettings.finance_budget_reset_day,
+        finance_budget_reset_timezone: localSettings.finance_budget_reset_timezone,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -2309,6 +2319,151 @@ export function SettingsPage() {
                     : t('settings.energyModeTotalDescription')}
                 </p>
               </div>
+              <div>
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white">{t('settings.billingEnabled')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.billingEnabledDescription')}</p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.billing_enabled ?? false}
+                      onChange={(e) => updateSetting('billing_enabled', e.target.checked)}
+                      className="peer sr-only"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
+                  </label>
+                </div>
+                {localSettings.billing_enabled && (
+                  <div className="mt-4 flex items-center justify-between gap-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark px-4 py-3">
+                    <div className="flex-1">
+                      <p className="text-white">
+                        {t('settings.printerKillSwitch', 'Unauthorized print kill switch')}
+                      </p>
+                      <p className="text-xs text-bambu-gray mt-1">
+                        {t(
+                          'settings.printerKillSwitchDescription',
+                          'Immediately stop prints that start without authorization.'
+                        )}
+                      </p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={localSettings.printer_kill_switch_enabled ?? false}
+                        onChange={(e) => updateSetting('printer_kill_switch_enabled', e.target.checked)}
+                        className="peer sr-only"
+                      />
+                      <div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
+                    </label>
+                  </div>
+                )}
+                {localSettings.billing_enabled && (
+                  <div className="mt-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-4">
+                    <div className="mb-3">
+                      <h4 className="text-base font-medium text-white flex items-center gap-2">
+                        <Calendar className="w-4 h-4 text-bambu-green" />
+                        {t('settings.financeBudgetReset', 'Finance Monthly Budget Reset')}
+                      </h4>
+                    </div>
+                    <div className="flex gap-4">
+                      <div className="flex-1">
+                        <label className="block text-xs text-bambu-gray mb-1">
+                          {t('settings.financeBudgetResetDay', 'Reset Day')}
+                        </label>
+                        <input
+                          type="number"
+                          min={1}
+                          max={31}
+                          value={localSettings.finance_budget_reset_day ?? 1}
+                          onChange={(e) =>
+                            updateSetting(
+                              'finance_budget_reset_day',
+                              Math.max(1, Math.min(31, parseInt(e.target.value, 10) || 1))
+                            )
+                          }
+                          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                        />
+                        <p className="text-xs text-bambu-gray mt-1">
+                          {t('settings.financeBudgetResetDayHelp', 'For short months, reset uses the last day of the month.')}
+                        </p>
+                      </div>
+                      <div className="flex-1">
+                        <label className="block text-xs text-bambu-gray mb-1">
+                          {t('settings.financeBudgetResetTimezone', 'Reset timezone')}
+                        </label>
+                        <select
+                          value={localSettings.finance_budget_reset_timezone ?? 'UTC'}
+                          onChange={(e) => updateSetting('finance_budget_reset_timezone', e.target.value)}
+                          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                        >
+                          <option value="UTC">UTC</option>
+                          <option value="Europe/Berlin">Europe/Berlin</option>
+                          <option value="Europe/Vienna">Europe/Vienna</option>
+                          <option value="Europe/Zurich">Europe/Zurich</option>
+                          <option value="America/New_York">America/New_York</option>
+                          <option value="America/Chicago">America/Chicago</option>
+                          <option value="America/Denver">America/Denver</option>
+                          <option value="America/Los_Angeles">America/Los_Angeles</option>
+                          <option value="Asia/Tokyo">Asia/Tokyo</option>
+                          <option value="Asia/Singapore">Asia/Singapore</option>
+                          <option value="Australia/Sydney">Australia/Sydney</option>
+                        </select>
+                        <p className="text-xs text-bambu-gray mt-1">
+                          {t('settings.financeBudgetResetTimezoneHelp', 'Budget window start is calculated in this timezone.')}
+                        </p>
+                      </div>
+                    </div>
+                  </div>
+                )}
+                {localSettings.billing_enabled && isAdmin && (
+                  <div className="mt-3">
+                    <Button
+                      variant="secondary"
+                      onClick={() => setShowRebuildConfirm(true)}
+                      disabled={isRebuildLoading}
+                    >
+                      {isRebuildLoading ? (
+                        <>
+                          <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                          {t('settings.rebuildLedgerInProgress', 'Starting...')}
+                        </>
+                      ) : (
+                        t('settings.rebuildLedger', 'Rebuild wallet ledger')
+                      )}
+                    </Button>
+
+                    {showRebuildConfirm && (
+                      <ConfirmModal
+                        title={t('settings.rebuildLedgerConfirmTitle', 'Rebuild wallet ledger?')}
+                        message={t(
+                          'settings.rebuildLedgerConfirmMessage',
+                          'This will rebuild the wallet ledger to repair historical balance values. Run this only if you know what you are doing.'
+                        )}
+                        confirmText={t('common.run', 'Run')}
+                        cancelText={t('common.cancel', 'Cancel')}
+                        isLoading={isRebuildLoading}
+                        loadingText={t('common.running', 'Running')}
+                        variant="warning"
+                        onCancel={() => setShowRebuildConfirm(false)}
+                        onConfirm={async () => {
+                          setIsRebuildLoading(true);
+                          try {
+                            await api.rebuildBalanceLedger();
+                            showToast(t('settings.rebuildLedgerStarted', 'Ledger rebuild started'), 'success');
+                            setShowRebuildConfirm(false);
+                          } catch (err) {
+                            showToast((err as Error).message || 'Failed to start ledger rebuild', 'error');
+                          } finally {
+                            setIsRebuildLoading(false);
+                          }
+                        }}
+                      />
+                    )}
+                  </div>
+                )}
+              </div>
             </CardContent>
           </Card>