diff --git a/backend/app/api/routes/finance.py b/backend/app/api/routes/finance.py index dcdfeb7da..1a4dabc8c 100644 --- a/backend/app/api/routes/finance.py +++ b/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: diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 2d3922f69..fff2cb882 100644 --- a/backend/app/core/database.py +++ b/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") diff --git a/backend/app/models/finance.py b/backend/app/models/finance.py index bf200e606..9758a0678 100644 --- a/backend/app/models/finance.py +++ b/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( diff --git a/backend/app/schemas/finance.py b/backend/app/schemas/finance.py index c7e6d7440..e81ba6b23 100644 --- a/backend/app/schemas/finance.py +++ b/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): diff --git a/backend/tests/integration/test_finance_api.py b/backend/tests/integration/test_finance_api.py index b33a1de0d..c7651f407 100644 --- a/backend/tests/integration/test_finance_api.py +++ b/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}, diff --git a/backend/tests/unit/test_finance_table_migration.py b/backend/tests/unit/test_finance_table_migration.py index 427a8172c..d68674036 100644 --- a/backend/tests/unit/test_finance_table_migration.py +++ b/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") - assert result.scalar_one() == "ix_cost_centers_code" + with pytest.raises(IntegrityError): + await conn.execute(text("INSERT INTO cost_centers (id, name, code) VALUES (2, 'Two', 'one')")) + + 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() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 47dadb195..14dfd96f4 100644 --- a/frontend/src/api/client.ts +++ b/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 { diff --git a/frontend/src/pages/FinancePage.tsx b/frontend/src/pages/FinancePage.tsx index 598d0edee..0ef1722af 100644 --- a/frontend/src/pages/FinancePage.tsx +++ b/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,