Преглед изворни кода

implemented pr (minor) feedback

behrinml пре 1 месец
родитељ
комит
2e5d36d680

+ 7 - 6
backend/app/api/routes/finance.py

@@ -38,6 +38,7 @@ from backend.app.schemas.finance import (
     WalletTransactionResponse,
     WalletTransactionResponse,
 )
 )
 from backend.app.services.finance_balance import (
 from backend.app.services.finance_balance import (
+    calculate_personal_balance,
     is_personal_transaction,
     is_personal_transaction,
     personal_balance_condition,
     personal_balance_condition,
     sync_personal_wallet_balance,
     sync_personal_wallet_balance,
@@ -410,8 +411,7 @@ async def get_my_balance(
 ):
 ):
     """Return the current user's wallet balance."""
     """Return the current user's wallet balance."""
     user = await _require_authenticated_user(current_user)
     user = await _require_authenticated_user(current_user)
-    wallet = await _get_or_create_wallet(db, user.id)
-    return _to_balance_response(wallet)
+    return await _get_wallet_balance_read_only(db, user.id)
 
 
 
 
 @router.get("/me/transactions", response_model=WalletTransactionListResponse)
 @router.get("/me/transactions", response_model=WalletTransactionListResponse)
@@ -576,6 +576,7 @@ async def edit_transaction(
     from backend.app.core.database import repair_wallet_ledger_internal
     from backend.app.core.database import repair_wallet_ledger_internal
 
 
     await repair_wallet_ledger_internal(db)
     await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
 
 
     return tx
     return tx
 
 
@@ -619,6 +620,7 @@ async def create_manual_print(
     from backend.app.core.database import repair_wallet_ledger_internal
     from backend.app.core.database import repair_wallet_ledger_internal
 
 
     await repair_wallet_ledger_internal(db)
     await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
 
 
     return tx
     return tx
 
 
@@ -679,8 +681,7 @@ async def get_user_balance(
     """Return a specific user's wallet balance."""
     """Return a specific user's wallet balance."""
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
     user = await _get_user_or_404(db, user_id)
     user = await _get_user_or_404(db, user_id)
-    wallet = await _get_or_create_wallet(db, user.id)
-    return _to_balance_response(wallet)
+    return await _get_wallet_balance_read_only(db, user.id)
 
 
 
 
 @router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
 @router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
@@ -874,10 +875,10 @@ async def update_cost_center(
     await _require_authenticated_user(current_user)
     await _require_authenticated_user(current_user)
     center = await _get_cost_center_or_404(db, cost_center_id)
     center = await _get_cost_center_or_404(db, cost_center_id)
 
 
-    if center.is_private and body.is_active is False:
+    if center.is_private:
         raise HTTPException(
         raise HTTPException(
             status_code=400,
             status_code=400,
-            detail="Private cost centers cannot be deactivated; set their budget to 0 to prevent printing",
+            detail=("Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing"),
         )
         )
 
 
     if body.name is not None:
     if body.name is not None:

+ 42 - 97
backend/app/core/database.py

@@ -1033,9 +1033,7 @@ async def _migrate_create_finance_tables(conn) -> None:
     PostgreSQL schema.  The finance column migrations below must therefore not
     PostgreSQL schema.  The finance column migrations below must therefore not
     assume these tables already exist.
     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.
+    ``UserWallet`` is mapped to ``user_wallets``.
     """
     """
     if is_sqlite():
     if is_sqlite():
         statements = [
         statements = [
@@ -1047,8 +1045,8 @@ async def _migrate_create_finance_tables(conn) -> None:
                 is_active BOOLEAN NOT NULL DEFAULT 1,
                 is_active BOOLEAN NOT NULL DEFAULT 1,
                 is_private BOOLEAN NOT NULL DEFAULT 0,
                 is_private BOOLEAN NOT NULL DEFAULT 0,
                 owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-                total_budget FLOAT,
-                monthly_budget FLOAT,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
                 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             )
@@ -1057,7 +1055,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             CREATE TABLE IF NOT EXISTS user_wallets (
             CREATE TABLE IF NOT EXISTS user_wallets (
                 id INTEGER PRIMARY KEY,
                 id INTEGER PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
-                balance FLOAT NOT NULL DEFAULT 0.0,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
                 currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             )
@@ -1073,25 +1071,13 @@ async def _migrate_create_finance_tables(conn) -> None:
             )
             )
             """,
             """,
             """
             """
-            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 (
             CREATE TABLE IF NOT EXISTS wallet_transactions (
                 id INTEGER PRIMARY KEY,
                 id INTEGER PRIMARY KEY,
                 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 transaction_type VARCHAR(40) NOT NULL,
                 transaction_type VARCHAR(40) NOT NULL,
-                amount FLOAT NOT NULL,
-                balance_after FLOAT,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
                 description TEXT,
                 description TEXT,
                 created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 print_run_id VARCHAR(100),
                 print_run_id VARCHAR(100),
@@ -1108,7 +1094,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             CREATE TABLE IF NOT EXISTS budget_reservations (
             CREATE TABLE IF NOT EXISTS budget_reservations (
                 id INTEGER PRIMARY KEY,
                 id INTEGER PRIMARY KEY,
                 cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
                 cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
-                amount FLOAT NOT NULL,
+                amount NUMERIC(14,2) NOT NULL,
                 status VARCHAR(20) NOT NULL,
                 status VARCHAR(20) NOT NULL,
                 source_type VARCHAR(50) NOT NULL,
                 source_type VARCHAR(50) NOT NULL,
                 source_id INTEGER,
                 source_id INTEGER,
@@ -1128,8 +1114,8 @@ async def _migrate_create_finance_tables(conn) -> None:
                 is_active BOOLEAN NOT NULL DEFAULT TRUE,
                 is_active BOOLEAN NOT NULL DEFAULT TRUE,
                 is_private BOOLEAN NOT NULL DEFAULT FALSE,
                 is_private BOOLEAN NOT NULL DEFAULT FALSE,
                 owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-                total_budget FLOAT,
-                monthly_budget FLOAT,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
                 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             )
@@ -1138,7 +1124,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             CREATE TABLE IF NOT EXISTS user_wallets (
             CREATE TABLE IF NOT EXISTS user_wallets (
                 id SERIAL PRIMARY KEY,
                 id SERIAL PRIMARY KEY,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
                 user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
-                balance FLOAT NOT NULL DEFAULT 0.0,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
                 currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
             )
@@ -1154,25 +1140,13 @@ async def _migrate_create_finance_tables(conn) -> None:
             )
             )
             """,
             """,
             """
             """
-            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 (
             CREATE TABLE IF NOT EXISTS wallet_transactions (
                 id SERIAL PRIMARY KEY,
                 id SERIAL PRIMARY KEY,
                 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 transaction_type VARCHAR(40) NOT NULL,
                 transaction_type VARCHAR(40) NOT NULL,
-                amount FLOAT NOT NULL,
-                balance_after FLOAT,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
                 description TEXT,
                 description TEXT,
                 created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 print_run_id VARCHAR(100),
                 print_run_id VARCHAR(100),
@@ -1189,7 +1163,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             CREATE TABLE IF NOT EXISTS budget_reservations (
             CREATE TABLE IF NOT EXISTS budget_reservations (
                 id SERIAL PRIMARY KEY,
                 id SERIAL PRIMARY KEY,
                 cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
                 cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
-                amount FLOAT NOT NULL,
+                amount NUMERIC(14,2) NOT NULL,
                 status VARCHAR(20) NOT NULL,
                 status VARCHAR(20) NOT NULL,
                 source_type VARCHAR(50) NOT NULL,
                 source_type VARCHAR(50) NOT NULL,
                 source_id INTEGER,
                 source_id INTEGER,
@@ -1206,15 +1180,15 @@ async def _migrate_create_finance_tables(conn) -> None:
 
 
 async def _migrate_create_finance_indexes(conn) -> None:
 async def _migrate_create_finance_indexes(conn) -> None:
     """Create finance indexes after legacy tables have received new columns."""
     """Create finance indexes after legacy tables have received new columns."""
+    # Older billing migrations created this as a non-unique index. Recreate it
+    # so upgraded databases enforce the same constraint as the ORM model.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS ix_cost_centers_code")
     indexes = [
     indexes = [
-        "CREATE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
         "CREATE INDEX IF NOT EXISTS ix_cost_centers_name ON cost_centers (name)",
         "CREATE 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 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_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_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_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_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_transaction_type ON wallet_transactions (transaction_type)",
@@ -1234,6 +1208,28 @@ async def _migrate_create_finance_indexes(conn) -> None:
         await _safe_execute(conn, statement)
         await _safe_execute(conn, statement)
 
 
 
 
+async def _migrate_finance_money_to_numeric(conn) -> None:
+    """Convert persisted finance money columns on PostgreSQL upgrades."""
+    if is_sqlite():
+        # SQLite uses dynamic type affinity. New tables declare NUMERIC, while
+        # existing values remain protected by cent-rounding at write/rebuild.
+        return
+
+    columns = {
+        "cost_centers": ("total_budget", "monthly_budget"),
+        "user_wallets": ("balance",),
+        "wallet_transactions": ("amount", "balance_after"),
+        "budget_reservations": ("amount",),
+    }
+    for table_name, column_names in columns.items():
+        for column_name in column_names:
+            await _safe_execute(
+                conn,
+                f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
+                f"TYPE NUMERIC(14,2) USING ROUND({column_name}::numeric, 2)",
+            )
+
+
 async def _migrate_add_print_archive_cost_center(conn) -> None:
 async def _migrate_add_print_archive_cost_center(conn) -> None:
     """Add the nullable cost-center link missing from pre-billing archives."""
     """Add the nullable cost-center link missing from pre-billing archives."""
     await _safe_execute(
     await _safe_execute(
@@ -1331,8 +1327,8 @@ async def run_migrations(conn):
         conn,
         conn,
         "ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
         "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 total_budget NUMERIC(14,2)")
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget NUMERIC(14,2)")
     timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
     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 created_at {timestamp_type}")
     await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
     await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
@@ -1374,6 +1370,7 @@ async def run_migrations(conn):
 
 
     # CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
     # 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.
     # Delay indexes until every legacy column they reference has been added.
+    await _migrate_finance_money_to_numeric(conn)
     await _migrate_create_finance_indexes(conn)
     await _migrate_create_finance_indexes(conn)
 
 
     # Migration: Add missing-spool-assignment print-start notification toggle
     # Migration: Add missing-spool-assignment print-start notification toggle
@@ -2996,58 +2993,6 @@ async def run_migrations(conn):
         " ON wallet_transactions (transaction_type, print_archive_id) WHERE print_run_id IS NULL",
         " ON wallet_transactions (transaction_type, print_archive_id) WHERE print_run_id IS NULL",
     )
     )
 
 
-    # Migration: Persist active budget reservations for accepted background dispatch jobs.
-    if is_sqlite():
-        await _safe_execute(
-            conn,
-            """
-            CREATE TABLE IF NOT EXISTS budget_reservations (
-                id INTEGER PRIMARY KEY,
-                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
-                amount FLOAT NOT NULL,
-                status VARCHAR(20),
-                source_type VARCHAR(50),
-                source_id INTEGER,
-                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
-                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-                released_at DATETIME
-            )
-            """,
-        )
-    else:
-        await _safe_execute(
-            conn,
-            """
-            CREATE TABLE IF NOT EXISTS budget_reservations (
-                id SERIAL PRIMARY KEY,
-                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
-                amount FLOAT NOT NULL,
-                status VARCHAR(20),
-                source_type VARCHAR(50),
-                source_id INTEGER,
-                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
-                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-                released_at TIMESTAMP
-            )
-            """,
-        )
-    await _safe_execute(
-        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id ON budget_reservations (cost_center_id)"
-    )
-    await _safe_execute(
-        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)"
-    )
-    await _safe_execute(
-        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)"
-    )
-    await _safe_execute(
-        conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)"
-    )
-    await _safe_execute(
-        conn,
-        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id ON budget_reservations (print_archive_id)",
-    )
-
     # Migration: Add backup_spools and backup_archives columns to github_backup_config
     # Migration: Add backup_spools and backup_archives columns to github_backup_config
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")

+ 7 - 7
backend/app/models/finance.py

@@ -5,7 +5,7 @@ from datetime import datetime
 from enum import Enum as PyEnum
 from enum import Enum as PyEnum
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
-from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, String, Text, UniqueConstraint, func
+from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
 from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -44,7 +44,7 @@ class UserWallet(Base):
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=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)
+    balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
     currency: Mapped[str] = mapped_column(String(3), default="EUR")
     currency: Mapped[str] = mapped_column(String(3), default="EUR")
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
@@ -63,8 +63,8 @@ class CostCenter(Base):
     is_private: Mapped[bool] = mapped_column(Boolean, default=False)
     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)
     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)
+    total_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+    monthly_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
 
 
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     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())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -101,7 +101,7 @@ class BudgetReservation(Base):
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
     cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
-    amount: Mapped[float] = mapped_column(Float)
+    amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
     status: Mapped[str] = mapped_column(String(20), default="active", index=True)
     status: Mapped[str] = mapped_column(String(20), default="active", index=True)
     source_type: Mapped[str] = mapped_column(String(50), index=True)
     source_type: Mapped[str] = mapped_column(String(50), index=True)
     source_id: Mapped[int | None] = mapped_column(index=True)
     source_id: Mapped[int | None] = mapped_column(index=True)
@@ -133,8 +133,8 @@ class WalletTransaction(Base):
     )
     )
 
 
     transaction_type: Mapped[str] = mapped_column(String(40), 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)
+    amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
+    balance_after: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
 
 
     created_by_user_id: Mapped[int | None] = mapped_column(
     created_by_user_id: Mapped[int | None] = mapped_column(

+ 5 - 5
backend/app/schemas/finance.py

@@ -76,15 +76,15 @@ class TransactionEditRequest(BaseModel):
 class ManualPrintRequest(BaseModel):
 class ManualPrintRequest(BaseModel):
     user_id: int
     user_id: int
     cost_center_id: int
     cost_center_id: int
-    amount: float
+    amount: float = Field(..., gt=0)
     description: str | None = None
     description: str | None = None
     created_at: datetime | None = None
     created_at: datetime | None = None
 
 
 
 
 class CostCenterCreateRequest(BaseModel):
 class CostCenterCreateRequest(BaseModel):
     name: str = Field(..., min_length=1, max_length=150)
     name: str = Field(..., min_length=1, max_length=150)
-    total_budget: float | None = None
-    monthly_budget: float | None = None
+    total_budget: float | None = Field(default=None, ge=0)
+    monthly_budget: float | None = Field(default=None, ge=0)
     is_active: bool = True
     is_active: bool = True
 
 
 
 
@@ -94,8 +94,8 @@ class CostCenterUpdateRequest(BaseModel):
 
 
 
 
 class CostCenterBudgetUpdateRequest(BaseModel):
 class CostCenterBudgetUpdateRequest(BaseModel):
-    total_budget: float | None = None
-    monthly_budget: float | None = None
+    total_budget: float | None = Field(default=None, ge=0)
+    monthly_budget: float | None = Field(default=None, ge=0)
 
 
 
 
 class CostCenterMemberRequest(BaseModel):
 class CostCenterMemberRequest(BaseModel):

+ 86 - 1
backend/tests/integration/test_finance_api.py

@@ -5,6 +5,7 @@ from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 
 
 from backend.app.core.auth import get_password_hash
 from backend.app.core.auth import get_password_hash
+from backend.app.core.database import repair_wallet_ledger_internal
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
 from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
 from backend.app.models.group import Group
 from backend.app.models.group import Group
@@ -73,6 +74,60 @@ class TestFinanceAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         return {"Authorization": f"Bearer {response.json()['access_token']}"}
         return {"Authorization": f"Bearer {response.json()['access_token']}"}
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_balance_get_does_not_create_wallet(
+        self,
+        async_client: AsyncClient,
+        auth_headers: dict[str, str],
+        db_session,
+    ):
+        target = User(
+            username="balance-without-wallet",
+            email="balance-without-wallet@example.com",
+            password_hash=get_password_hash("Regularpass1!"),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(target)
+        await db_session.commit()
+        await db_session.refresh(target)
+        assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == target.id)) is None
+
+        response = await async_client.get(f"/api/v1/finance/users/{target.id}/balance", headers=auth_headers)
+
+        assert response.status_code == 200
+        assert response.json()["balance"] == 0
+        assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == target.id)) is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wallet_ledger_rebuild_processes_more_than_one_batch(
+        self,
+        db_session,
+        admin_user: User,
+    ):
+        wallet = UserWallet(user_id=admin_user.id, balance=0)
+        db_session.add(wallet)
+        await db_session.flush()
+        await db_session.execute(
+            WalletTransaction.__table__.insert(),
+            [{"user_id": admin_user.id, "transaction_type": "deposit", "amount": 0.01} for _ in range(1001)],
+        )
+
+        await repair_wallet_ledger_internal(db_session)
+        await db_session.refresh(wallet)
+        last_transaction = await db_session.scalar(
+            select(WalletTransaction)
+            .where(WalletTransaction.user_id == admin_user.id)
+            .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+            .limit(1)
+        )
+
+        assert wallet.balance == 10.01
+        assert last_transaction is not None
+        assert last_transaction.balance_after == 10.01
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_create_cost_center_assign_member_and_list_mine(
     async def test_create_cost_center_assign_member_and_list_mine(
@@ -85,6 +140,13 @@ class TestFinanceAPI:
         created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
         created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
         user_headers = await self._login_user(async_client, "carol")
         user_headers = await self._login_user(async_client, "carol")
 
 
+        negative_budget_response = await async_client.post(
+            "/api/v1/finance/cost-centers",
+            json={"name": "Invalid Budget", "total_budget": -1},
+            headers=auth_headers,
+        )
+        assert negative_budget_response.status_code == 422
+
         create_response = await async_client.post(
         create_response = await async_client.post(
             "/api/v1/finance/cost-centers",
             "/api/v1/finance/cost-centers",
             json={
             json={
@@ -165,6 +227,22 @@ class TestFinanceAPI:
         await db_session.refresh(private_center)
         await db_session.refresh(private_center)
         assert private_center.is_active is True
         assert private_center.is_active is True
 
 
+        rename_response = await async_client.patch(
+            f"/api/v1/finance/cost-centers/{private_center.id}",
+            json={"name": "Renamed private center"},
+            headers=auth_headers,
+        )
+        assert rename_response.status_code == 400
+        await db_session.refresh(private_center)
+        assert private_center.name == "private-budget-user"
+
+        negative_budget_response = await async_client.patch(
+            f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
+            json={"monthly_budget": -0.01},
+            headers=auth_headers,
+        )
+        assert negative_budget_response.status_code == 422
+
         budget_response = await async_client.patch(
         budget_response = await async_client.patch(
             f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
             f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
             json={"total_budget": 0},
             json={"total_budget": 0},
@@ -723,7 +801,7 @@ class TestFinanceAPI:
         payload = {
         payload = {
             "user_id": user.id,
             "user_id": user.id,
             "cost_center_id": private_cc.id,
             "cost_center_id": private_cc.id,
-            "amount": -4.0,
+            "amount": 4.0,
             "description": "Manual adjustment for a print",
             "description": "Manual adjustment for a print",
             "created_at": "2026-05-12T12:00:00Z",
             "created_at": "2026-05-12T12:00:00Z",
         }
         }
@@ -741,6 +819,13 @@ class TestFinanceAPI:
         # The response includes the computed running balance for the transaction
         # The response includes the computed running balance for the transaction
         assert resp_json.get("balance_after") == -4.0
         assert resp_json.get("balance_after") == -4.0
 
 
+        negative_amount_response = await async_client.post(
+            "/api/v1/finance/transactions/manual",
+            json={**payload, "amount": -1},
+            headers=auth_headers,
+        )
+        assert negative_amount_response.status_code == 422
+
         invalid_user_response = await async_client.post(
         invalid_user_response = await async_client.post(
             "/api/v1/finance/transactions/manual",
             "/api/v1/finance/transactions/manual",
             json={**payload, "user_id": 2147483647},
             json={**payload, "user_id": 2147483647},

+ 51 - 4
backend/tests/unit/test_finance_table_migration.py

@@ -5,12 +5,14 @@ from unittest.mock import patch
 
 
 import pytest
 import pytest
 from sqlalchemy import text
 from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import create_async_engine
 from sqlalchemy.ext.asyncio import create_async_engine
 
 
 from backend.app.core.database import (
 from backend.app.core.database import (
     _migrate_add_print_archive_cost_center,
     _migrate_add_print_archive_cost_center,
     _migrate_create_finance_indexes,
     _migrate_create_finance_indexes,
     _migrate_create_finance_tables,
     _migrate_create_finance_tables,
+    _migrate_finance_money_to_numeric,
 )
 )
 
 
 EXPECTED_TABLES = {
 EXPECTED_TABLES = {
@@ -69,13 +71,17 @@ async def test_legacy_cost_center_indexes_are_delayed_until_columns_exist():
                 await _migrate_create_finance_tables(conn)
                 await _migrate_create_finance_tables(conn)
 
 
             await conn.execute(text("ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)"))
             await conn.execute(text("ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)"))
+            await conn.execute(text("CREATE INDEX ix_cost_centers_code ON cost_centers (code)"))
+            await conn.execute(text("INSERT INTO cost_centers (id, name, code) VALUES (1, 'One', 'one')"))
             await _migrate_create_finance_indexes(conn)
             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'")
-            )
+            result = await conn.execute(text("PRAGMA index_list(cost_centers)"))
+            code_index = next(row for row in result if row[1] == "ix_cost_centers_code")
+
+            with pytest.raises(IntegrityError):
+                await conn.execute(text("INSERT INTO cost_centers (id, name, code) VALUES (2, 'Two', 'one')"))
 
 
-        assert result.scalar_one() == "ix_cost_centers_code"
+        assert code_index[2] == 1
     finally:
     finally:
         await engine.dispose()
         await engine.dispose()
 
 
@@ -133,6 +139,26 @@ async def test_postgres_finance_ddl_uses_postgres_types():
     assert created_tables == EXPECTED_TABLES
     assert created_tables == EXPECTED_TABLES
 
 
 
 
+@pytest.mark.asyncio
+async def test_postgres_finance_money_columns_are_migrated_to_numeric():
+    statements: list[str] = []
+
+    async def capture_statement(_conn, sql: str) -> None:
+        statements.append(sql)
+
+    with (
+        patch("backend.app.core.database.is_sqlite", return_value=False),
+        patch("backend.app.core.database._safe_execute", side_effect=capture_statement),
+    ):
+        await _migrate_finance_money_to_numeric(object())
+
+    assert len(statements) == 6
+    assert all("TYPE NUMERIC(14,2)" in sql for sql in statements)
+    assert all("USING ROUND(" in sql for sql in statements)
+    assert any("wallet_transactions ALTER COLUMN amount" in sql for sql in statements)
+    assert any("wallet_transactions ALTER COLUMN balance_after" in sql for sql in statements)
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_finance_tables_are_created_idempotently_on_postgres():
 async def test_finance_tables_are_created_idempotently_on_postgres():
     database_url = os.getenv("BAMBUDDY_TEST_POSTGRES_URL")
     database_url = os.getenv("BAMBUDDY_TEST_POSTGRES_URL")
@@ -151,6 +177,14 @@ async def test_finance_tables_are_created_idempotently_on_postgres():
             with patch("backend.app.core.database.is_sqlite", return_value=False):
             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_tables(conn)
                 await _migrate_create_finance_tables(conn)
+                await conn.execute(
+                    text(
+                        "ALTER TABLE wallet_transactions ALTER COLUMN amount "
+                        "TYPE DOUBLE PRECISION USING amount::double precision"
+                    )
+                )
+                await _migrate_finance_money_to_numeric(conn)
+                await _migrate_finance_money_to_numeric(conn)
                 await _migrate_add_print_archive_cost_center(conn)
                 await _migrate_add_print_archive_cost_center(conn)
                 await _migrate_add_print_archive_cost_center(conn)
                 await _migrate_add_print_archive_cost_center(conn)
                 await _migrate_create_finance_indexes(conn)
                 await _migrate_create_finance_indexes(conn)
@@ -170,6 +204,17 @@ async def test_finance_tables_are_created_idempotently_on_postgres():
                     "AND table_name = 'cost_centers' AND column_name = 'created_at'"
                     "AND table_name = 'cost_centers' AND column_name = 'created_at'"
                 )
                 )
             )
             )
+            money_types = await conn.execute(
+                text(
+                    "SELECT table_name, column_name, data_type, numeric_precision, numeric_scale "
+                    "FROM information_schema.columns "
+                    "WHERE table_schema = 'public' AND (table_name, column_name) IN ("
+                    "('cost_centers', 'total_budget'), ('cost_centers', 'monthly_budget'), "
+                    "('user_wallets', 'balance'), ('wallet_transactions', 'amount'), "
+                    "('wallet_transactions', 'balance_after'), ('budget_reservations', 'amount'))"
+                )
+            )
+            money_type_rows = money_types.all()
             archive_cost_center = await conn.execute(
             archive_cost_center = await conn.execute(
                 text(
                 text(
                     "SELECT c.data_type, rc.delete_rule "
                     "SELECT c.data_type, rc.delete_rule "
@@ -189,6 +234,8 @@ async def test_finance_tables_are_created_idempotently_on_postgres():
 
 
         assert {row[0] for row in rows} == EXPECTED_TABLES
         assert {row[0] for row in rows} == EXPECTED_TABLES
         assert timestamp_type.scalar_one() == "timestamp without time zone"
         assert timestamp_type.scalar_one() == "timestamp without time zone"
+        assert len(money_type_rows) == 6
+        assert all(row[2:] == ("numeric", 14, 2) for row in money_type_rows)
         assert archive_cost_center.one() == ("integer", "SET NULL")
         assert archive_cost_center.one() == ("integer", "SET NULL")
     finally:
     finally:
         await engine.dispose()
         await engine.dispose()

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

@@ -2562,6 +2562,7 @@ export interface PrintQueueBulkUpdate {
   // Auto-print G-code injection
   // Auto-print G-code injection
   gcode_injection?: boolean;
   gcode_injection?: boolean;
   cost_center_id?: number | null;
   cost_center_id?: number | null;
+  estimated_cost?: number | null;
 }
 }
 
 
 export interface PrintQueueBulkUpdateResponse {
 export interface PrintQueueBulkUpdateResponse {

+ 2 - 2
frontend/src/pages/FinancePage.tsx

@@ -489,7 +489,7 @@ export function FinancePage() {
       data: {
       data: {
         user_id: editTransactionUserId || undefined,
         user_id: editTransactionUserId || undefined,
         cost_center_id: editTransactionCostCenterId || undefined,
         cost_center_id: editTransactionCostCenterId || undefined,
-        amount: amount || undefined,
+        amount: amount ?? undefined,
         description: editTransactionDescription || undefined,
         description: editTransactionDescription || undefined,
       },
       },
     });
     });
@@ -510,7 +510,7 @@ export function FinancePage() {
       showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
       showToast(t('finance.amountInvalid', 'Amount must be a valid number'), 'error');
       return;
       return;
     }
     }
-    if (amount > 0) amount = -Math.abs(amount);
+    amount = Math.abs(amount);
 
 
     manualPrintMutation.mutate({
     manualPrintMutation.mutate({
       user_id: manualPrintUserId,
       user_id: manualPrintUserId,