bambuddy/backend/tests/integration/test_finance_api.py
2026-08-06 20:28:16 +02:00

1193 lines
45 KiB
Python

"""Integration tests for the finance/billing API."""
import pytest
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
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.finance_billing import apply_print_charge_for_archive
class TestFinanceAPI:
@pytest.fixture
async def admin_user(self, db_session):
user = User(
username="finance-admin",
email="finance-admin@example.com",
password_hash=get_password_hash("AdminPass1!"),
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
db_session.add(Settings(key="auth_enabled", value="true"))
db_session.add(Settings(key="advanced_auth_enabled", value="false"))
# Ensure billing is enabled for finance integration tests
existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
if existing is None:
db_session.add(Settings(key="billing_enabled", value="true"))
else:
existing.value = "true"
await db_session.commit()
response = await async_client.post(
"/api/v1/auth/login",
json={"username": admin_user.username, "password": "AdminPass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _enable_basic_user_creation(self, db_session):
return None
async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
response = await async_client.post(
"/api/v1/users",
json={
"username": username,
"password": "Regularpass1!",
"email": f"{username}@example.com",
"role": "user",
},
headers=auth_headers,
)
assert response.status_code == 201
return response.json()
async def _login_user(self, async_client: AsyncClient, username: str) -> dict[str, str]:
response = await async_client.post(
"/api/v1/auth/login",
json={"username": username, "password": "Regularpass1!"},
)
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(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
await self._enable_basic_user_creation(db_session)
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={
"name": "Shared Lab",
"monthly_budget": 120.0,
"total_budget": 999.0,
"is_active": True,
},
headers=auth_headers,
)
assert create_response.status_code == 200
shared_center = create_response.json()
assert shared_center["name"] == "Shared Lab"
assert shared_center["monthly_budget"] == 120.0
assert shared_center["total_budget"] is None
assert shared_center["budget_mode"] == "monthly"
member_response = await async_client.post(
f"/api/v1/finance/cost-centers/{shared_center['id']}/members",
json={"user_id": created_user["id"], "can_print": False},
headers=auth_headers,
)
assert member_response.status_code == 200
assert member_response.json()["user_id"] == created_user["id"]
assert member_response.json()["can_print"] is False
mine_response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
assert mine_response.status_code == 200
mine_names = {center["name"] for center in mine_response.json()}
assert "carol" in mine_names
assert "Shared Lab" in mine_names
detail_response = await async_client.get(
f"/api/v1/finance/cost-centers/{shared_center['id']}", headers=auth_headers
)
assert detail_response.status_code == 200
detail = detail_response.json()
assert len(detail["members"]) == 1
assert detail["members"][0]["user_id"] == created_user["id"]
remove_response = await async_client.delete(
f"/api/v1/finance/cost-centers/{shared_center['id']}/members/{created_user['id']}",
headers=auth_headers,
)
assert remove_response.status_code == 200
mine_after_remove = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
assert mine_after_remove.status_code == 200
assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_private_cost_center_cannot_be_deactivated_but_can_have_zero_budget(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
created_user = await self._create_user_via_api(async_client, auth_headers, "private-budget-user")
private_center = await db_session.scalar(
select(CostCenter).where(
CostCenter.owner_user_id == created_user["id"],
CostCenter.is_private.is_(True),
)
)
assert private_center is not None
deactivate_response = await async_client.patch(
f"/api/v1/finance/cost-centers/{private_center.id}",
json={"is_active": False},
headers=auth_headers,
)
assert deactivate_response.status_code == 400
assert "cannot be deactivated" in deactivate_response.json()["detail"]
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},
headers=auth_headers,
)
assert budget_response.status_code == 200
assert budget_response.json()["total_budget"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cost_center_available_budget_does_not_double_count_queue_reservation(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
center_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Reserved Once", "total_budget": 10.0},
headers=auth_headers,
)
assert center_response.status_code == 200
center_id = center_response.json()["id"]
reserved_item = PrintQueueItem(
cost_center_id=center_id,
estimated_cost=3.0,
status="pending",
position=1,
)
legacy_unreserved_item = PrintQueueItem(
cost_center_id=center_id,
estimated_cost=2.0,
status="pending",
position=2,
)
db_session.add_all([reserved_item, legacy_unreserved_item])
await db_session.flush()
db_session.add(
BudgetReservation(
cost_center_id=center_id,
amount=3.0,
status="active",
source_type="print_queue",
source_id=reserved_item.id,
)
)
await db_session.commit()
response = await async_client.get("/api/v1/finance/cost-centers", headers=auth_headers)
assert response.status_code == 200
center = next(item for item in response.json() if item["id"] == center_id)
# 3.00 active reservation + 2.00 legacy queue estimate, not 3 + 3 + 2.
assert center["budget_available"] == 5.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cost_center_with_balanced_transactions_cannot_be_deleted(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_user,
db_session,
):
center_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Balanced History"},
headers=auth_headers,
)
center_id = center_response.json()["id"]
db_session.add_all(
[
WalletTransaction(
user_id=admin_user.id,
cost_center_id=center_id,
transaction_type="deposit",
amount=50.0,
balance_after=50.0,
),
WalletTransaction(
user_id=admin_user.id,
cost_center_id=center_id,
transaction_type="withdraw",
amount=-50.0,
balance_after=0.0,
),
]
)
await db_session.commit()
response = await async_client.delete(
f"/api/v1/finance/cost-centers/{center_id}",
headers=auth_headers,
)
assert response.status_code == 400
assert "transactions reference it" in response.json()["detail"]
transactions = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.cost_center_id == center_id)))
.scalars()
.all()
)
assert len(transactions) == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cost_center_with_active_reservation_cannot_be_deleted(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
center_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Active Hold"},
headers=auth_headers,
)
center_id = center_response.json()["id"]
db_session.add(
BudgetReservation(
cost_center_id=center_id,
amount=3.0,
status="active",
source_type="direct_print",
source_id=123,
)
)
await db_session.commit()
response = await async_client.delete(
f"/api/v1/finance/cost-centers/{center_id}",
headers=auth_headers,
)
assert response.status_code == 400
assert "active budget reservations" in response.json()["detail"]
assert await db_session.get(CostCenter, center_id) is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_wallet_adjustments_and_transaction_ledger_rebuild(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_user,
db_session,
):
"""A user's private cost center and unassigned entries share one wallet."""
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
)
assert private_center is not None
# The owner's private cost center affects both its own ledger and the wallet.
deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": private_center.id},
headers=auth_headers,
)
assert deposit.status_code == 200
assert deposit.json()["transaction"]["cost_center_id"] == private_center.id
assert deposit.json()["transaction"]["balance_after"] == 25.0 # CC balance
assert deposit.json()["balance"]["balance"] == 25.0 # Response shows CC balance
# A withdrawal updates both views by the same amount.
withdraw = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/withdraw",
json={"amount": 5.0, "description": "CC Usage", "cost_center_id": private_center.id},
headers=auth_headers,
)
assert withdraw.status_code == 200
assert withdraw.json()["transaction"]["amount"] == -5.0
assert withdraw.json()["transaction"]["balance_after"] == 20.0 # CC balance after withdraw
assert withdraw.json()["balance"]["balance"] == 20.0 # Response shows CC balance
# Personal deposit: affects user wallet
personal_deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 30.0, "description": "Personal top-up", "cost_center_id": None},
headers=auth_headers,
)
assert personal_deposit.status_code == 200
assert personal_deposit.json()["transaction"]["cost_center_id"] is None
assert personal_deposit.json()["transaction"]["balance_after"] == 50.0
assert personal_deposit.json()["balance"]["balance"] == 50.0
transactions_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert transactions_response.status_code == 200
transactions = transactions_response.json()
assert len(transactions) == 3
cc_txs = [tx for tx in transactions if tx["cost_center_id"] == private_center.id]
personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
assert len(cc_txs) == 2
assert len(personal_txs) == 1
balance_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_response.status_code == 200
assert balance_response.json()["balance"] == 50.0
# Delete personal transaction, user wallet should decrease
personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{personal_tx['id']}", headers=auth_headers
)
assert delete_response.status_code == 200
balance_after_delete = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_after_delete.status_code == 200
assert balance_after_delete.json()["balance"] == 20.0
remaining = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert remaining.status_code == 200
assert len(remaining.json()) == 2 # 2 CC transactions remain
@pytest.mark.asyncio
@pytest.mark.integration
async def test_admin_and_user_balances_agree_after_charge_adjustment_and_delete(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
created_user = await self._create_user_via_api(async_client, auth_headers, "balance-lifecycle")
user = await db_session.get(User, created_user["id"])
operators = await db_session.scalar(select(Group).where(Group.name == "Operators"))
assert operators is not None
user.groups.append(operators)
await db_session.commit()
user_headers = await self._login_user(async_client, "balance-lifecycle")
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
assert private_center is not None
deposit = await async_client.post(
f"/api/v1/finance/users/{user.id}/deposit",
json={"amount": 20.0, "cost_center_id": private_center.id},
headers=auth_headers,
)
assert deposit.status_code == 200
archive = PrintArchive(
filename="charged.gcode.3mf",
file_path="archives/test/charged.gcode.3mf",
file_size=10,
status="completed",
cost=4.0,
created_by_id=user.id,
cost_center_id=private_center.id,
)
db_session.add(archive)
await db_session.commit()
assert await apply_print_charge_for_archive(db_session, archive.id) is True
await db_session.commit()
adjustment = await async_client.post(
f"/api/v1/finance/users/{user.id}/deposit",
json={"amount": 5.0, "description": "temporary adjustment"},
headers=auth_headers,
)
assert adjustment.status_code == 200
async def assert_views_agree(expected: float):
admin_view = await async_client.get(
f"/api/v1/finance/users/{user.id}/balance",
headers=auth_headers,
)
user_view = await async_client.get("/api/v1/finance/me/balance", headers=user_headers)
assert admin_view.status_code == 200
assert user_view.status_code == 200
assert admin_view.json()["balance"] == expected
assert user_view.json()["balance"] == expected
await assert_views_agree(21.0)
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{adjustment.json()['transaction']['id']}",
headers=auth_headers,
)
assert delete_response.status_code == 200
await assert_views_agree(16.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "erin")
shared_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
)
assert shared_center is not None
first_deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 25.0, "description": "CC top-up", "cost_center_id": shared_center.id},
headers=auth_headers,
)
assert first_deposit.status_code == 200
cc_withdraw = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/withdraw",
json={"amount": 5.0, "description": "CC usage", "cost_center_id": shared_center.id},
headers=auth_headers,
)
assert cc_withdraw.status_code == 200
personal_deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 12.0, "description": "Personal top-up", "cost_center_id": None},
headers=auth_headers,
)
assert personal_deposit.status_code == 200
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{first_deposit.json()['transaction']['id']}",
headers=auth_headers,
)
assert delete_response.status_code == 200
transactions_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert transactions_response.status_code == 200
transactions = transactions_response.json()
assert len(transactions) == 2
cc_transaction = next(tx for tx in transactions if tx["cost_center_id"] == shared_center.id)
assert cc_transaction["amount"] == -5.0
assert cc_transaction["balance_after"] == -5.0
balance_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_response.status_code == 200
assert balance_response.json()["balance"] == 7.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_print_charge_stays_deleted_after_recalculate(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
user_id = user.id
archive = PrintArchive(
printer_id=None,
filename="print.gcode",
file_path="archives/test/print.gcode",
file_size=10,
content_hash="hash-print",
status="completed",
cost=4.0,
created_by_id=user.id,
)
db_session.add(archive)
await db_session.flush()
tx = WalletTransaction(
user_id=user.id,
transaction_type="print_charge",
amount=-4.0,
balance_after=-4.0,
description="Print charge: print.gcode",
created_by_user_id=None,
print_run_id="deleted-print-run",
print_archive_id=archive.id,
)
db_session.add(tx)
await db_session.commit()
archive_id = archive.id
tx_rows_before = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
.scalars()
.all()
)
assert len(tx_rows_before) == 1
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
)
assert delete_response.status_code == 200
db_session.expire_all()
tx_rows_after = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
.scalars()
.all()
)
assert len(tx_rows_after) == 1
assert tx_rows_after[0].is_voided is True
# The voided run remains an idempotency tombstone and cannot be
# recreated by a delayed duplicate completion callback.
assert (
await apply_print_charge_for_archive(
db_session,
archive_id,
print_run_id="deleted-print-run",
)
) is False
# A later reprint of the same archive has a distinct run identity and
# must still be charged normally.
assert (
await apply_print_charge_for_archive(
db_session,
archive_id,
charged_user_id=user_id,
print_run_id="later-reprint-run",
)
) is True
await db_session.commit()
visible = await async_client.get(
f"/api/v1/finance/users/{user_id}/transactions",
headers=auth_headers,
)
assert visible.status_code == 200
assert [row["print_run_id"] for row in visible.json()] == ["later-reprint-run"]
async def test_edit_transaction_updates_ledger(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Test that editing a transaction (user, cost_center, amount, description) rebuilds ledger."""
await self._enable_basic_user_creation(db_session)
user1 = await self._create_user_via_api(async_client, auth_headers, "user1")
user2 = await self._create_user_via_api(async_client, auth_headers, "user2")
# Create a cost center
cc_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Test Center", "is_active": True},
headers=auth_headers,
)
assert cc_response.status_code == 200
cost_center = cc_response.json()
# Get user records from DB
user1_db = await db_session.scalar(select(User).where(User.id == user1["id"]))
user2_db = await db_session.scalar(select(User).where(User.id == user2["id"]))
# Create a personal transaction for user1
tx_response = await async_client.post(
f"/api/v1/finance/users/{user1_db.id}/deposit",
json={"amount": 50.0, "description": "Initial deposit"},
headers=auth_headers,
)
assert tx_response.status_code == 200
tx_data = tx_response.json()
tx_id = tx_data["transaction"]["id"]
# Get the original transaction
original_tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.id == tx_id))
assert original_tx.user_id == user1_db.id
assert original_tx.cost_center_id is None
assert original_tx.amount == 50.0
assert original_tx.balance_after == 50.0
# Edit the transaction: change user, add cost center, change amount
edit_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={
"user_id": user2_db.id,
"cost_center_id": cost_center["id"],
"amount": 75.0,
"description": "Updated deposit (Admin edit)",
},
headers=auth_headers,
)
assert edit_response.status_code == 200
edited_tx_data = edit_response.json()
# Verify transaction was updated
assert edited_tx_data["user_id"] == user2_db.id
assert edited_tx_data["cost_center_id"] == cost_center["id"]
assert edited_tx_data["amount"] == 75.0
# Description should have "(Admin edit)" appended
assert "(Admin edit)" in edited_tx_data["description"]
# An explicit null moves the transaction back to the personal ledger.
clear_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={"cost_center_id": None},
headers=auth_headers,
)
assert clear_response.status_code == 200
assert clear_response.json()["cost_center_id"] is None
invalid_user_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={"user_id": 2147483647},
headers=auth_headers,
)
assert invalid_user_response.status_code == 404
assert invalid_user_response.json()["detail"] == "User not found"
invalid_center_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={"cost_center_id": 2147483647},
headers=auth_headers,
)
assert invalid_center_response.status_code == 404
assert invalid_center_response.json()["detail"] == "Cost center not found"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_manual_print_and_recalculates_ledger(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Posting a manual print (manual_adjustment) creates a transaction and rebuilds ledger."""
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "gina")
# Get user DB record
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Private cost center for user
private_cc = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
)
assert private_cc is not None
# Post manual print affecting the cost center
payload = {
"user_id": user.id,
"cost_center_id": private_cc.id,
"amount": 4.0,
"description": "Manual adjustment for a print",
"created_at": "2026-05-12T12:00:00Z",
}
response = await async_client.post("/api/v1/finance/transactions/manual", json=payload, headers=auth_headers)
assert response.status_code == 200
resp_json = response.json()
assert "transaction" in resp_json or "id" in resp_json
# Response contains the created transaction details
assert resp_json["transaction_type"] == "manual_adjustment"
assert resp_json["amount"] == -4.0
assert resp_json["cost_center_id"] == private_cc.id
# 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},
headers=auth_headers,
)
assert invalid_user_response.status_code == 404
assert invalid_user_response.json()["detail"] == "User not found"
invalid_center_response = await async_client.post(
"/api/v1/finance/transactions/manual",
json={**payload, "cost_center_id": 2147483647},
headers=auth_headers,
)
assert invalid_center_response.status_code == 404
assert invalid_center_response.json()["detail"] == "Cost center not found"
class TestPartialPrintChargesIntegration:
"""Integration tests for partial print charge calculation."""
@pytest.fixture
async def admin_user(self, db_session):
user = User(
username="partial-admin",
email="partial-admin@example.com",
password_hash=get_password_hash("AdminPass1!"),
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
db_session.add(Settings(key="auth_enabled", value="true"))
db_session.add(Settings(key="advanced_auth_enabled", value="false"))
# Ensure billing is enabled for these partial-charge integration tests
existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
if existing is None:
db_session.add(Settings(key="billing_enabled", value="true"))
else:
existing.value = "true"
await db_session.commit()
response = await async_client.post(
"/api/v1/auth/login",
json={"username": admin_user.username, "password": "AdminPass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
response = await async_client.post(
"/api/v1/users",
json={
"username": username,
"password": "Regularpass1!",
"email": f"{username}@example.com",
"role": "user",
},
headers=auth_headers,
)
assert response.status_code == 201
return response.json()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_aborted_print_charges_proportionally_via_recalculate_endpoint(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify aborted prints are included in recalculate and charged proportionally."""
created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Wallet is already created by ensure_user_finance_defaults during user creation
# Archive: completed print (100% charge)
completed = PrintArchive(
printer_id=None,
filename="completed.3mf",
file_path="archives/test/completed.3mf",
file_size=100,
content_hash="partial-complete",
status="completed",
cost=10.0,
created_by_id=user.id,
)
# Archive: aborted print (50% filament used = 50% charge)
aborted = PrintArchive(
printer_id=None,
filename="aborted.3mf",
file_path="archives/test/aborted.3mf",
file_size=100,
content_hash="partial-aborted",
status="aborted",
cost=8.0,
filament_used_grams=50.0,
extra_data={"filament_grams_total": 100.0},
created_by_id=user.id,
)
# Archive: failed print (0% filament used = no charge)
failed = PrintArchive(
printer_id=None,
filename="failed.3mf",
file_path="archives/test/failed.3mf",
file_size=100,
content_hash="partial-failed",
status="failed",
cost=5.0,
filament_used_grams=0.0,
created_by_id=user.id,
)
db_session.add_all([completed, aborted, failed])
await db_session.commit()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_charges_appear_in_transaction_ledger(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify transaction descriptions indicate partial charges."""
created_user = await self._create_user_via_api(async_client, auth_headers, "grace")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Wallet is already created by ensure_user_finance_defaults during user creation
cancelled = PrintArchive(
printer_id=None,
filename="cancelled.3mf",
file_path="archives/test/cancelled.3mf",
file_size=100,
content_hash="partial-cancel",
status="cancelled",
cost=12.0,
filament_used_grams=25.0,
extra_data={"filament_grams_total": 100.0},
print_name="Partially Cancelled Print",
created_by_id=user.id,
)
db_session.add(cancelled)
await db_session.commit()
from backend.app.services.finance_billing import apply_print_charge_for_archive
changed = await apply_print_charge_for_archive(db_session, cancelled.id)
assert changed is True
await db_session.commit()
tx_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert tx_response.status_code == 200
transactions = tx_response.json()
assert len(transactions) == 1
tx = transactions[0]
assert tx["transaction_type"] == "print_charge"
assert tx["amount"] == -3.0 # 25% of 12.0
assert "cancelled" in tx["description"].lower()
assert "25.0g/100.0" in tx["description"] # filament amounts in description
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_charges_with_cost_center_override(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify partial charges respect cost_center_id when present."""
created_user = await self._create_user_via_api(async_client, auth_headers, "henry")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Create cost centers
default_cc = CostCenter(name="Default CC", owner_user_id=user.id, is_active=True, is_private=False)
lab_cc = CostCenter(name="Lab CC", owner_user_id=user.id, is_active=True, is_private=False)
db_session.add_all([default_cc, lab_cc])
await db_session.flush()
# Wallet is already created by ensure_user_finance_defaults during user creation
# Archive assigned to default_cc
aborted = PrintArchive(
printer_id=None,
filename="aborted_cc.3mf",
file_path="archives/test/aborted_cc.3mf",
file_size=100,
content_hash="partial-cc",
status="aborted",
cost=6.0,
filament_used_grams=30.0,
extra_data={"filament_grams_total": 100.0},
cost_center_id=default_cc.id,
created_by_id=user.id,
)
db_session.add(aborted)
await db_session.commit()
# Manually apply charge with override
from backend.app.services.finance_billing import apply_print_charge_for_archive
changed = await apply_print_charge_for_archive(
db_session,
aborted.id,
cost_center_id=lab_cc.id,
)
await db_session.commit()
assert changed is True
tx_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert tx_response.status_code == 200
transactions = tx_response.json()
assert len(transactions) == 1
tx = transactions[0]
assert tx["cost_center_id"] == lab_cc.id # Overridden to lab_cc
assert tx["amount"] == pytest.approx(-1.8, abs=0.01) # 30% of 6.0
class TestFinanceUserDefaults:
"""Tests for user creation and finance defaults initialization."""
@pytest.fixture
async def admin_user(self, db_session):
user = User(
username="billing-admin",
email="billing-admin@example.com",
password_hash=get_password_hash("AdminPass1!"),
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
db_session.add(Settings(key="auth_enabled", value="true"))
db_session.add(Settings(key="advanced_auth_enabled", value="false"))
await db_session.commit()
response = await async_client.post(
"/api/v1/auth/login",
json={"username": admin_user.username, "password": "AdminPass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
response = await async_client.post(
"/api/v1/users",
json={
"username": username,
"password": "Regularpass1!",
"email": f"{username}@example.com",
"role": "user",
},
headers=auth_headers,
)
assert response.status_code == 201
return response.json()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_initializes_wallet_and_private_cost_center(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify user creation initializes wallet, private cost center, and membership."""
result = await async_client.post(
"/api/v1/users",
json={
"username": "alice",
"password": "Regularpass1!",
"email": "alice@example.com",
"role": "user",
},
headers=auth_headers,
)
assert result.status_code == 201
created = result.json()
assert created["username"] == "alice"
user = await db_session.scalar(select(User).where(User.username == "alice"))
assert user is not None
from backend.app.models.finance import CostCenterMember
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
assert wallet is not None
assert wallet.balance == 0.0
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
assert private_center is not None
assert private_center.name == "alice"
membership = await db_session.scalar(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == private_center.id,
CostCenterMember.user_id == user.id,
)
)
assert membership is not None
assert membership.can_print is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_user_keeps_private_cost_center_in_sync(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify user updates keep private cost center name in sync."""
created = await self._create_user_via_api(async_client, auth_headers, "bob")
response = await async_client.patch(
f"/api/v1/users/{created['id']}",
json={"username": "bobby"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["username"] == "bobby"
user = await db_session.scalar(select(User).where(User.id == created["id"]))
assert user is not None
private_centers = (
(
await db_session.execute(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
)
.scalars()
.all()
)
assert len(private_centers) == 1
assert private_centers[0].name == "bobby"
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
assert wallet is not None