mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
implemented pr (minor) feedback
This commit is contained in:
parent
dd1d40b0d4
commit
2e5d36d680
8 changed files with 201 additions and 122 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue