Jelajahi Sumber

PR feedback integrated

behrinml 1 bulan lalu
induk
melakukan
b74862d7d2

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

@@ -36,7 +36,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 validate_print_budget
+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.utils.printer_models import (
     is_gcode_compatible,
@@ -1000,6 +1000,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"
@@ -1242,6 +1248,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()
 
@@ -1354,6 +1366,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)

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

+ 227 - 3
backend/app/core/database.py

@@ -1024,6 +1024,217 @@ 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 +1249,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.
@@ -1100,15 +1316,19 @@ async def run_migrations(conn):
 
     # Migration: Add cost_center columns expected by current finance model
     await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
-    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
+    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")
-    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN created_at DATETIME")
-    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN updated_at DATETIME")
+    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():
@@ -1145,6 +1365,10 @@ async def run_migrations(conn):
     # 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():

+ 58 - 7
backend/app/services/finance_budget.py

@@ -90,9 +90,19 @@ async def _cost_center_open_queue_reservations(
     *,
     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)
@@ -101,13 +111,25 @@ async def _cost_center_open_queue_reservations(
     return float(result.scalar() or 0.0)
 
 
-async def _cost_center_active_budget_reservations(db: AsyncSession, cost_center_id: int) -> float:
-    result = await db.execute(
-        select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(
-            BudgetReservation.cost_center_id == cost_center_id,
-            BudgetReservation.status == "active",
+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)
 
 
@@ -119,6 +141,8 @@ async def validate_print_budget(
     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):
@@ -160,7 +184,12 @@ async def validate_print_budget(
         cost_center_id,
         exclude_queue_item_id=exclude_queue_item_id,
     )
-    reserved += await _cost_center_active_budget_reservations(db, cost_center_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:
@@ -179,6 +208,7 @@ async def create_budget_reservation(
     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
@@ -191,7 +221,28 @@ async def create_budget_reservation(
         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),

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

@@ -33,7 +33,11 @@ from backend.app.services.bambu_ftp import (
     with_ftp_retry,
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
-from backend.app.services.finance_budget import validate_print_budget
+from backend.app.services.finance_budget import (
+    create_budget_reservation,
+    release_budget_reservation,
+    validate_print_budget,
+)
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
     printer_manager,
@@ -289,6 +293,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."""
@@ -970,6 +979,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
@@ -1001,6 +1015,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.
 
@@ -3094,6 +3140,10 @@ 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
 
@@ -3104,7 +3154,20 @@ class PrintScheduler:
                 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)
@@ -3240,6 +3303,8 @@ class PrintScheduler:
                 )
                 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)
@@ -3680,6 +3745,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
@@ -3967,6 +4033,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,

+ 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

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

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

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

@@ -56,6 +56,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         connected=True,
         state="RUNNING",
         progress=0,
+        remaining_time=0,
         layer_num=0,
         temperatures={},
         raw_data={},
@@ -106,6 +107,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
         connected=True,
         state="RUNNING",
         progress=0,
+        remaining_time=0,
         layer_num=0,
         temperatures={},
         raw_data={},
@@ -154,6 +156,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         connected=True,
         state="RUNNING",
         progress=0,
+        remaining_time=0,
         layer_num=0,
         temperatures={},
         raw_data={},
@@ -175,6 +178,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         connected=True,
         state="IDLE",
         progress=0,
+        remaining_time=0,
         layer_num=0,
         temperatures={},
         raw_data={},

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

+ 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',
@@ -123,6 +124,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
@@ -2243,6 +2334,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',
@@ -4678,6 +4783,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

+ 3 - 1
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',
@@ -123,6 +124,7 @@ export default {
     duplicate: 'Dupliquer',
     left: 'Gauche',
     right: 'Droite',
+    user: 'Utilisateur',
     run: 'Exécuter',
     running: 'En cours d\'exécution...',
   },
@@ -140,7 +142,7 @@ export default {
     addManualPrint: 'Ajouter un tirage manuel',
     manageMembers: 'Gérer les membres du centre de coûts',
     currentBalance: 'Solde personnel',
-    transactions: 'Transactions',
+    transactions: 'Opérations',
     personalTransactions: 'Transactions personnelles',
     costCenters: 'Centres de coûts',
     availableForPrinting: 'Disponible pour l\'attribution d\'impression',

+ 2 - 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',
@@ -123,6 +124,7 @@ export default {
     duplicate: 'Duplica',
     left: 'Sinistra',
     right: 'Destra',
+    user: 'Utente',
     run: 'Esegui',
     running: 'In esecuzione...',
   },

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'メンテナンス',
     projects: 'プロジェクト',
     inventory: 'フィラメント',
+    finance: 'ファイナンス',
     files: 'ファイル管理',
     makerworld: 'MakerWorld',
     notifications: '通知',
@@ -123,6 +124,7 @@ export default {
     duplicate: '複製',
     left: '左',
     right: '右',
+    user: 'ユーザー',
     run: '実行',
     running: '実行中...',
   },

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

@@ -8,6 +8,7 @@ export default {
     maintenance: '유지보수',
     projects: '프로젝트',
     inventory: '필라멘트',
+    finance: '재무',
     files: '파일 관리자',
     makerworld: 'MakerWorld',
     notifications: '알림',
@@ -119,7 +120,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: '프린터',
@@ -2113,6 +2203,20 @@ export default {
     },
     externalCameras: '외부 카메라',
     costTracking: '비용 추적',
+    billingEnabled: '결제 기능 사용',
+    billingEnabledDescription: '사용자에게 인쇄 비용을 청구하고 재무 기능을 사용합니다',
+    printerKillSwitch: '무단 인쇄 자동 중지',
+    printerKillSwitchDescription: '승인 없이 시작된 인쇄를 즉시 중지합니다.',
+    financeBudgetReset: '월별 재무 예산 초기화',
+    financeBudgetResetDay: '초기화 날짜',
+    financeBudgetResetDayHelp: '해당 날짜가 없는 짧은 달에는 그 달의 마지막 날에 초기화합니다.',
+    financeBudgetResetTimezone: '초기화 시간대',
+    financeBudgetResetTimezoneHelp: '예산 기간의 시작은 이 시간대를 기준으로 계산됩니다.',
+    rebuildLedger: '지갑 원장 재구성',
+    rebuildLedgerStarted: '원장 재구성을 시작했습니다',
+    rebuildLedgerConfirmTitle: '지갑 원장을 재구성하시겠습니까?',
+    rebuildLedgerConfirmMessage: '과거 잔액 값을 복구하기 위해 지갑 원장을 재구성합니다. 수행 내용을 정확히 아는 경우에만 실행하세요.',
+    rebuildLedgerInProgress: '재구성 시작 중...',
     printsOnly: '인쇄만',
     totalConsumption: '총 소비',
     dataManagement: '데이터 관리',
@@ -4441,7 +4545,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 비밀을 복호화할 수 있습니다.',

+ 2 - 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',
@@ -123,6 +124,7 @@ export default {
     duplicate: 'Duplicar',
     left: 'Esquerda',
     right: 'Direita',
+    user: 'Usuário',
     run: 'Executar',
     running: 'Executando...',
   },

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

@@ -8,6 +8,7 @@ export default {
     maintenance: "Обслуживание",
     projects: "Проекты",
     inventory: "Филамент",
+    finance: "Финансы",
     files: "Файловый менеджер",
     makerworld: "MakerWorld",
     notifications: "Уведомления",
@@ -120,6 +121,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: "Принтеры",
@@ -2114,6 +2204,20 @@ export default {
     },
     externalCameras: "Внешние камеры",
     costTracking: "Учёт затрат",
+    billingEnabled: "Включить расчёты",
+    billingEnabledDescription: "Списывать с пользователей стоимость печати и включить финансовые функции",
+    printerKillSwitch: "Автоостановка несанкционированной печати",
+    printerKillSwitchDescription: "Немедленно останавливает печать, начатую без разрешения.",
+    financeBudgetReset: "Ежемесячный сброс финансового бюджета",
+    financeBudgetResetDay: "День сброса",
+    financeBudgetResetDayHelp: "В коротких месяцах сброс выполняется в последний день месяца.",
+    financeBudgetResetTimezone: "Часовой пояс сброса",
+    financeBudgetResetTimezoneHelp: "Начало бюджетного периода рассчитывается в этом часовом поясе.",
+    rebuildLedger: "Пересчитать книгу кошелька",
+    rebuildLedgerStarted: "Пересчёт книги запущен",
+    rebuildLedgerConfirmTitle: "Пересчитать книгу кошелька?",
+    rebuildLedgerConfirmMessage: "Книга кошелька будет пересчитана для исправления исторических значений баланса. Запускайте это действие только если понимаете его последствия.",
+    rebuildLedgerInProgress: "Запуск пересчёта...",
     printsOnly: "Только печать",
     totalConsumption: "Общее потребление",
     dataManagement: "Управление данными",
@@ -4431,6 +4535,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',
@@ -123,6 +124,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ı
@@ -2244,6 +2335,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',
@@ -4648,6 +4753,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: "Сповіщення",
@@ -123,6 +124,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
@@ -2259,6 +2350,20 @@ export default {
     },
     externalCameras: "Зовнішні камери",
     costTracking: "Відстеження витрат",
+    billingEnabled: "Увімкнути розрахунки",
+    billingEnabledDescription: "Стягувати з користувачів вартість друку та ввімкнути фінансові функції",
+    printerKillSwitch: "Автозупинка несанкціонованого друку",
+    printerKillSwitchDescription: "Негайно зупиняє друк, розпочатий без дозволу.",
+    financeBudgetReset: "Щомісячне скидання фінансового бюджету",
+    financeBudgetResetDay: "День скидання",
+    financeBudgetResetDayHelp: "У коротких місяцях скидання виконується в останній день місяця.",
+    financeBudgetResetTimezone: "Часовий пояс скидання",
+    financeBudgetResetTimezoneHelp: "Початок бюджетного періоду обчислюється в цьому часовому поясі.",
+    rebuildLedger: "Перебудувати книгу гаманця",
+    rebuildLedgerStarted: "Перебудову книги розпочато",
+    rebuildLedgerConfirmTitle: "Перебудувати книгу гаманця?",
+    rebuildLedgerConfirmMessage: "Книгу гаманця буде перебудовано для виправлення історичних значень балансу. Запускайте цю дію лише якщо розумієте її наслідки.",
+    rebuildLedgerInProgress: "Запуск перебудови...",
     printsOnly: "Лише друк",
     totalConsumption: "Загальне споживання",
     dataManagement: "Управління даними",
@@ -4713,6 +4818,7 @@ export default {
     staggerTotal: "всього: {{minutes}} хв",
     staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
     gcodeInjection: "Додати G-код автоматичного друку",
+    insufficientBudget: "Недостатньо бюджету",
   },
 
   // Backup

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

@@ -124,6 +124,7 @@ export default {
     duplicate: '複製',
     left: '左',
     right: '右',
+    user: '使用者',
     run: '執行',
     running: '執行中...',
   },