implemented pr (minor) feedback

This commit is contained in:
behrinml 2026-08-06 19:12:43 +02:00
parent dd1d40b0d4
commit 2e5d36d680
8 changed files with 201 additions and 122 deletions

View file

@ -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:

View file

@ -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")

View file

@ -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(

View file

@ -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):

View file

@ -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},

View file

@ -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()

View file

@ -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 {

View file

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