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

implemented pr (minor) feedback

behrinml 1 месяц назад
Родитель
Сommit
2e5d36d680

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

@@ -38,6 +38,7 @@ from backend.app.schemas.finance import (
     WalletTransactionResponse,
 )
 from backend.app.services.finance_balance import (
+    calculate_personal_balance,
     is_personal_transaction,
     personal_balance_condition,
     sync_personal_wallet_balance,
@@ -410,8 +411,7 @@ async def get_my_balance(
 ):
     """Return the current user's wallet balance."""
     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)
@@ -576,6 +576,7 @@ async def edit_transaction(
     from backend.app.core.database import repair_wallet_ledger_internal
 
     await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
 
     return tx
 
@@ -619,6 +620,7 @@ async def create_manual_print(
     from backend.app.core.database import repair_wallet_ledger_internal
 
     await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
 
     return tx
 
@@ -679,8 +681,7 @@ async def get_user_balance(
     """Return a specific user's wallet balance."""
     await _require_authenticated_user(current_user)
     user = await _get_user_or_404(db, user_id)
-    wallet = await _get_or_create_wallet(db, user.id)
-    return _to_balance_response(wallet)
+    return await _get_wallet_balance_read_only(db, user.id)
 
 
 @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)
     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(
             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:

+ 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
     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():
         statements = [
@@ -1047,8 +1045,8 @@ async def _migrate_create_finance_tables(conn) -> None:
                 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,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
                 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
@@ -1057,7 +1055,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             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,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
                 currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 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 (
                 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,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
                 description TEXT,
                 created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 print_run_id VARCHAR(100),
@@ -1108,7 +1094,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             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,
+                amount NUMERIC(14,2) NOT NULL,
                 status VARCHAR(20) NOT NULL,
                 source_type VARCHAR(50) NOT NULL,
                 source_id INTEGER,
@@ -1128,8 +1114,8 @@ async def _migrate_create_finance_tables(conn) -> None:
                 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,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
                 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
             )
@@ -1138,7 +1124,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             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,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
                 currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
                 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 (
                 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,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
                 description TEXT,
                 created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 print_run_id VARCHAR(100),
@@ -1189,7 +1163,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             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,
+                amount NUMERIC(14,2) NOT NULL,
                 status VARCHAR(20) NOT NULL,
                 source_type VARCHAR(50) NOT NULL,
                 source_id INTEGER,
@@ -1206,15 +1180,15 @@ async def _migrate_create_finance_tables(conn) -> None:
 
 async def _migrate_create_finance_indexes(conn) -> None:
     """Create finance indexes after legacy tables have received new columns."""
+    # Older billing migrations created this as a non-unique index. Recreate it
+    # so upgraded databases enforce the same constraint as the ORM model.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS ix_cost_centers_code")
     indexes = [
-        "CREATE 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 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)",
@@ -1234,6 +1208,28 @@ async def _migrate_create_finance_indexes(conn) -> None:
         await _safe_execute(conn, statement)
 
 
+async def _migrate_finance_money_to_numeric(conn) -> None:
+    """Convert persisted finance money columns on PostgreSQL upgrades."""
+    if is_sqlite():
+        # SQLite uses dynamic type affinity. New tables declare NUMERIC, while
+        # existing values remain protected by cent-rounding at write/rebuild.
+        return
+
+    columns = {
+        "cost_centers": ("total_budget", "monthly_budget"),
+        "user_wallets": ("balance",),
+        "wallet_transactions": ("amount", "balance_after"),
+        "budget_reservations": ("amount",),
+    }
+    for table_name, column_names in columns.items():
+        for column_name in column_names:
+            await _safe_execute(
+                conn,
+                f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
+                f"TYPE NUMERIC(14,2) USING ROUND({column_name}::numeric, 2)",
+            )
+
+
 async def _migrate_add_print_archive_cost_center(conn) -> None:
     """Add the nullable cost-center link missing from pre-billing archives."""
     await _safe_execute(
@@ -1331,8 +1327,8 @@ async def run_migrations(conn):
         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 total_budget NUMERIC(14,2)")
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget NUMERIC(14,2)")
     timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
     await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN created_at {timestamp_type}")
     await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
@@ -1374,6 +1370,7 @@ async def run_migrations(conn):
 
     # CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
     # Delay indexes until every legacy column they reference has been added.
+    await _migrate_finance_money_to_numeric(conn)
     await _migrate_create_finance_indexes(conn)
 
     # Migration: Add missing-spool-assignment print-start notification toggle
@@ -2996,58 +2993,6 @@ async def run_migrations(conn):
         " 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
     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")

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

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

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

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

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

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

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

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

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

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