| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108 |
- """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.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_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")
- 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
- 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
- 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
|